├── .config └── dotnet-tools.json ├── .gitattributes ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── CONTRIBUTING.md ├── GitVersion.yml ├── License.md ├── README.md ├── azure-pipelines.yml ├── build.ps1 └── src └── SimpleEventStore ├── Directory.Build.props ├── SimpleEventStore.CosmosDb.Tests ├── AzureCosmosDbEventStoreAppending.cs ├── AzureCosmosDbEventStoreAppendingWithConverters.cs ├── AzureCosmosDbEventStoreInitializing.cs ├── AzureCosmosDbEventStoreLogging.cs ├── AzureCosmosDbEventStoreReading.cs ├── AzureCosmosDbEventStoreReadingPartiallyDeletedStreams.cs ├── AzureCosmosDbStorageEngineBuilderTests.cs ├── ConfigurableTypeMapSerializationBinderTests.cs ├── CosmosClientFactory.cs ├── CosmosDbStorageEngineFactory.cs ├── CosmosDbStorageEventTests.cs ├── PerformanceTests.cs ├── ResponseInformationBuilding.cs ├── SimpleEventStore.CosmosDb.Tests.csproj ├── TestConstants.cs └── appsettings.json ├── SimpleEventStore.CosmosDb ├── AzureCosmosDbStorageEngine.cs ├── AzureCosmosDbStorageEngineBuilder.cs ├── CollectionOptions.cs ├── ConfigurableSerializationTypeMap.cs ├── CosmosDbStorageEvent.cs ├── DatabaseOptions.cs ├── DefaultSerializationTypeMap.cs ├── ISerializationTypeMap.cs ├── JsonNetCosmosSerializer.cs ├── LoggingOptions.cs ├── Resources.cs ├── ResponseInformation.cs ├── SimpleEventStore.AzureDocumentDb.nuspec └── SimpleEventStore.CosmosDb.csproj ├── SimpleEventStore.Tests ├── EventDataTests.cs ├── EventStoreAppending.cs ├── EventStoreReading.cs ├── EventStoreTestBase.cs ├── Events │ ├── OrderCreated.cs │ ├── OrderDispatched.cs │ ├── OrderProcessed.cs │ └── TestMetadata.cs ├── InMemory │ ├── InMemoryEventStoreAppending.cs │ └── InMemoryEventStoreReading.cs ├── SimpleEventStore.Tests.csproj └── StorageEventTests.cs ├── SimpleEventStore.sln └── SimpleEventStore ├── ConcurrencyException.cs ├── EventData.cs ├── EventStore.cs ├── Guard.cs ├── IStorageEngine.cs ├── InMemory └── InMemoryStorageEngine.cs ├── SimpleEventStore.csproj └── StorageEvent.cs /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "gitversion.tool": { 6 | "version": "5.1.2", 7 | "commands": [ 8 | "dotnet-gitversion" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | *.VC.db 84 | *.VC.VC.opendb 85 | 86 | # Visual Studio profiler 87 | *.psess 88 | *.vsp 89 | *.vspx 90 | *.sap 91 | 92 | # TFS 2012 Local Workspace 93 | $tf/ 94 | 95 | # Guidance Automation Toolkit 96 | *.gpState 97 | 98 | # ReSharper is a .NET coding add-in 99 | _ReSharper*/ 100 | *.[Rr]e[Ss]harper 101 | *.DotSettings.user 102 | 103 | # JustCode is a .NET coding add-in 104 | .JustCode 105 | 106 | # TeamCity is a build add-in 107 | _TeamCity* 108 | 109 | # DotCover is a Code Coverage Tool 110 | *.dotCover 111 | 112 | # NCrunch 113 | _NCrunch_* 114 | .*crunch*.local.xml 115 | nCrunchTemp_* 116 | 117 | # MightyMoose 118 | *.mm.* 119 | AutoTest.Net/ 120 | 121 | # Web workbench (sass) 122 | .sass-cache/ 123 | 124 | # Installshield output folder 125 | [Ee]xpress/ 126 | 127 | # DocProject is a documentation generator add-in 128 | DocProject/buildhelp/ 129 | DocProject/Help/*.HxT 130 | DocProject/Help/*.HxC 131 | DocProject/Help/*.hhc 132 | DocProject/Help/*.hhk 133 | DocProject/Help/*.hhp 134 | DocProject/Help/Html2 135 | DocProject/Help/html 136 | 137 | # Click-Once directory 138 | publish/ 139 | 140 | # Publish Web Output 141 | *.[Pp]ublish.xml 142 | *.azurePubxml 143 | # TODO: Comment the next line if you want to checkin your web deploy settings 144 | # but database connection strings (with potential passwords) will be unencrypted 145 | *.pubxml 146 | *.publishproj 147 | 148 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 149 | # checkin your Azure Web App publish settings, but sensitive information contained 150 | # in these scripts will be unencrypted 151 | PublishScripts/ 152 | 153 | # NuGet Packages 154 | *.nupkg 155 | # The packages folder can be ignored because of Package Restore 156 | **/packages/* 157 | # except build/, which is used as an MSBuild target. 158 | !**/packages/build/ 159 | # Uncomment if necessary however generally it will be regenerated when needed 160 | #!**/packages/repositories.config 161 | # NuGet v3's project.json files produces more ignoreable files 162 | *.nuget.props 163 | *.nuget.targets 164 | 165 | # Microsoft Azure Build Output 166 | csx/ 167 | *.build.csdef 168 | 169 | # Microsoft Azure Emulator 170 | ecf/ 171 | rcf/ 172 | 173 | # Windows Store app package directories and files 174 | AppPackages/ 175 | BundleArtifacts/ 176 | Package.StoreAssociation.xml 177 | _pkginfo.txt 178 | 179 | # Visual Studio cache files 180 | # files ending in .cache can be ignored 181 | *.[Cc]ache 182 | # but keep track of directories ending in .cache 183 | !*.[Cc]ache/ 184 | 185 | # Others 186 | ClientBin/ 187 | ~$* 188 | *~ 189 | *.dbmdl 190 | *.dbproj.schemaview 191 | *.pfx 192 | *.publishsettings 193 | node_modules/ 194 | orleans.codegen.cs 195 | 196 | # Since there are multiple workflows, uncomment next line to ignore bower_components 197 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 198 | #bower_components/ 199 | 200 | # RIA/Silverlight projects 201 | Generated_Code/ 202 | 203 | # Backup & report files from converting an old project file 204 | # to a newer Visual Studio version. Backup files are not needed, 205 | # because we have git ;-) 206 | _UpgradeReport_Files/ 207 | Backup*/ 208 | UpgradeLog*.XML 209 | UpgradeLog*.htm 210 | 211 | # SQL Server files 212 | *.mdf 213 | *.ldf 214 | 215 | # Business Intelligence projects 216 | *.rdl.data 217 | *.bim.layout 218 | *.bim_*.settings 219 | 220 | # Microsoft Fakes 221 | FakesAssemblies/ 222 | 223 | # GhostDoc plugin setting file 224 | *.GhostDoc.xml 225 | 226 | # Node.js Tools for Visual Studio 227 | .ntvs_analysis.dat 228 | 229 | # Visual Studio 6 build log 230 | *.plg 231 | 232 | # Visual Studio 6 workspace options file 233 | *.opt 234 | 235 | # Visual Studio LightSwitch build output 236 | **/*.HTMLClient/GeneratedArtifacts 237 | **/*.DesktopClient/GeneratedArtifacts 238 | **/*.DesktopClient/ModelManifest.xml 239 | **/*.Server/GeneratedArtifacts 240 | **/*.Server/ModelManifest.xml 241 | _Pvt_Extensions 242 | 243 | # Paket dependency manager 244 | .paket/paket.exe 245 | paket-files/ 246 | 247 | # FAKE - F# Make 248 | .fake/ 249 | 250 | # JetBrains Rider 251 | .idea/ 252 | *.sln.iml 253 | 254 | # Custom ignores 255 | /output 256 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Attach", 9 | "type": "coreclr", 10 | "request": "attach", 11 | "processId": "${command:pickProcess}" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "command": "dotnet", 4 | "args": [], 5 | "tasks": [ 6 | { 7 | "label": "build", 8 | "type": "shell", 9 | "command": "powershell", 10 | "args": [ 11 | "./build.ps1", 12 | "-ConsistencyLevel", 13 | "Session" 14 | ], 15 | "problemMatcher": "$msCompile", 16 | "group": "build" 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Building Simple Event Store 4 | Clone repository 5 | ```sh 6 | git clone git@github.com:ASOS/SimpleEventStore.git 7 | ``` 8 | 9 | Enter the repository and build the provider and run the tests using cake 10 | ```sh 11 | cd build 12 | ./build.ps1 13 | ``` 14 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | mode: Mainline 2 | branches: {} 3 | ignore: 4 | sha: [] -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 ASOS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Event Store 2 | 3 | Simple Event Store (SES) provides a lightweight event sourcing abstraction. 4 | 5 | ## Key Features 6 | - Full support for async/await for all persistence engines 7 | - Optimistic concurrency for append operations 8 | - Reading streams forward 9 | 10 | ## Persistence Engines 11 | SES supports the following 12 | - Azure Cosmos DB 13 | - In Memory 14 | 15 | All persistence engines run the same test scenarios to ensure feature parity. At present only the Cosmos DB persistence engine should be considered for production usage. 16 | 17 | ## Usage 18 | 19 | ### Creation 20 | ```csharp 21 | var storageEngine = new InMemoryStorageEngine(); 22 | var eventStore = new EventStore(storageEngine); 23 | ``` 24 | Do not use storage engines directly, only interact with the event store using the EventStore class. There should only be a single instance of the EventStore per process. Creating transient instances will lead to a decrease in performance. 25 | 26 | ### Appending Events 27 | ```csharp 28 | var expectedRevision = 0; 29 | 30 | await eventStore.AppendToStream(streamId, expectedRevision, new EventData(Guid.NewGuid(), new OrderCreated(streamId))); 31 | ``` 32 | The expected stream revision would either be set to 0 for a new stream, or to the expected event number. If the latest event number in the database differs then a concurrency exception will be thrown. 33 | 34 | ### Reading Events 35 | ```csharp 36 | var events = await eventStore.ReadStreamForwards(streamId, startPosition: 2, numberOfEventsToRead: 1); 37 | // or 38 | var events = await eventStore.ReadStreamForwards(streamId); 39 | ``` 40 | You can either read all events in a stream, or a subset of events. Only read all events if you know the maximum size of a stream is going to be low and that you always need to read all events as part of your workload e.g. replaying events to project current state for a DDD aggregate. 41 | 42 | ## Cosmos DB 43 | ```csharp 44 | CosmosClient client; // Set this up as required 45 | 46 | // If UseCollection isn't specified, sensible defaults for development are used. 47 | // If UseSubscriptions isn't supplied the subscription feature is disabled. 48 | // If UseSharedThroughput isn't supplied the throughput is set only at a collection level 49 | return await new AzureCosmosDbStorageEngineBuilder(client, databaseName) 50 | .UseSharedThroughput(o => { 51 | o.DatabaseRequestUnits = 400; 52 | }) 53 | .UseCollection(o => 54 | { 55 | o.ConsistencyLevel = consistencyLevelEnum; 56 | o.CollectionRequestUnits = 400; 57 | }) 58 | .UseLogging(o => 59 | { 60 | o.Success = onSuccessCallback; 61 | }) 62 | .UseTypeMap(new ConfigurableSerializationTypeMap() 63 | .RegisterTypes( 64 | typeof(OrderCreated).GetTypeInfo().Assembly, 65 | t => t.Namespace.EndsWith("Events"), 66 | t => t.Name)) 67 | .UseJsonSerializerSettings(settings) 68 | .Build() 69 | .Initialise(); 70 | ``` 71 | ### Database Options 72 | Use this only if you want throughput to be set at a database level 73 | 74 | Allows you to specify 75 | - The number of RUs for the database 76 | 77 | 78 | ### CollectionOptions 79 | Allows you to specify 80 | - The consistency level of the database 81 | - The number of RUs for the collection - if throughput is set at a database level this cannot be greater than database throughput 82 | - The collection name 83 | 84 | Only use one of the following consistency levels 85 | - Strong 86 | - Bounded Staleness - use this if you need to geo-replicate the database 87 | 88 | ### UseLogging 89 | Sets up callbacks per Cosmos DB operation performed. This is useful if you want to record per call data e.g. RU cost of each operation. 90 | 91 | ### UseTypeMap 92 | Allows you to control the event body/metadata type names. Built in implementations 93 | - DefaultSerializationTypeMap - uses the AssemblyQualifiedName of the type. (default) 94 | - ConfigurableSerializationTypeMap - provides full control. 95 | 96 | While the default implementation is simple, this isn't great for versioning as contract assembly version number changes will render events unreadable. Therefore the configurable implementation or your own implementation is recommended. 97 | 98 | ### UseJsonSerializerSettings 99 | Allows you to customise JSON serialization of the event body and metadata. If you do not call this method a default JsonSerializerSettings instance is used. 100 | 101 | For consistent serialization provide the same serialzer settings to your DocumentClient. 102 | 103 | ### Initialise 104 | Calling the operation creates the underlying collection based on the DatabaseOptions. This ensures the required stored procedure is present too. It is safe to call this multiple times. 105 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # https://aka.ms/yaml 2 | 3 | trigger: 4 | - main 5 | - main 6 | 7 | pr: 8 | - main 9 | 10 | pool: 11 | vmImage: windows-latest 12 | stages: 13 | - stage: Build 14 | displayName: Build 15 | jobs: 16 | - job: Build 17 | steps: 18 | - checkout: self 19 | fetchDepth: 0 20 | - task: gitversion/setup@0 21 | displayName: Install GitVersion 22 | inputs: 23 | versionSpec: '5.x' 24 | - task: gitversion/execute@0 25 | displayName: Determine GitVersion 26 | inputs: 27 | useConfigFile: true 28 | configFilePath: $(Build.SourcesDirectory)/GitVersion.yml 29 | targetPath: $(Build.SourcesDirectory) 30 | - task: PowerShell@2 31 | displayName: Starting Cosmos DB Emulator 32 | inputs: 33 | containerName: 'azure-cosmosdb-emulator' 34 | enableAPI: 'SQL' 35 | portMapping: '8081:8081, 8901:8901, 8902:8902, 8979:8979, 10250:10250, 10251:10251, 10252:10252, 10253:10253, 10254:10254, 10255:10255, 10256:10256, 10350:10350' 36 | hostDirectory: '$(Build.BinariesDirectory)\azure-cosmosdb-emulator' 37 | consistency: 'BoundedStaleness' 38 | targetType: 'inline' 39 | workingDirectory: $(Pipeline.Workspace) 40 | script: | 41 | Write-Host "Starting CosmosDB Emulator" 42 | Import-Module "C:/Program Files/Azure Cosmos DB Emulator/PSModules/Microsoft.Azure.CosmosDB.Emulator" 43 | Start-CosmosDbEmulator 44 | 45 | - task: PowerShell@2 46 | displayName: Build and Test 47 | inputs: 48 | filePath: '.\build.ps1' 49 | # arguments: '-Uri $(CosmosDbEmulator.Endpoint)' 50 | # - task: PublishTestResults@2 51 | # condition: succeededOrFailed() 52 | # inputs: 53 | # testRunner: VSTest 54 | # testResultsFiles: '**/*.trx' 55 | 56 | - task: DotNetCoreCLI@2 57 | displayName: Package 58 | inputs: 59 | command: "pack" 60 | versioningScheme: byEnvVar 61 | versionEnvVar: NuGetVersion 62 | packDestination: $(Build.ArtifactStagingDirectory)/package 63 | arguments: "-o $(Build.ArtifactStagingDirectory)/package" 64 | projects: | 65 | **/*.csproj 66 | 67 | - task: PublishBuildArtifacts@1 68 | displayName: Publish Build Artifacts 69 | inputs: 70 | PathtoPublish: '$(Build.ArtifactStagingDirectory)' 71 | ArtifactName: 'drop' 72 | publishLocation: 'Container' 73 | 74 | - stage: Deploy 75 | displayName: Deploy 76 | jobs: 77 | - job: Deploy 78 | condition: 79 | or( 80 | in(variables['Build.SourceBranch'], 'refs/heads/main'), 81 | in(variables['Build.Reason'], 'Manual') 82 | ) 83 | steps: 84 | - task: DownloadBuildArtifacts@0 85 | displayName: 'Download artifacts' 86 | inputs: 87 | buildType: 'current' 88 | downloadType: 'single' 89 | artifactName: 'drop' 90 | itemPattern: 91 | downloadPath: '$(System.ArtifactsDirectory)' 92 | 93 | - task: NuGetCommand@2 94 | displayName: Publish package to nuget.org using ASOS organisation account 95 | inputs: 96 | command: 'push' 97 | packagesToPush: '$(System.ArtifactsDirectory)/drop/*.nupkg;!$(System.ArtifactsDirectory)/drop/*.symbols.nupkg' 98 | nuGetFeedType: 'external' 99 | publishFeedCredentials: 'ASOS nuget.org feed' -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | Param( 2 | [string]$Uri = "https://localhost:8081/", 3 | [string]$AuthKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", 4 | [string]$ConsistencyLevel = "BoundedStaleness", 5 | [string]$Configuration = "Release" 6 | ) 7 | 8 | function Write-Stage([string]$name) 9 | { 10 | Write-Host $('=' * 30) -ForegroundColor Green 11 | Write-Host $name -ForegroundColor Green 12 | Write-Host $('=' * 30) -ForegroundColor Green 13 | } 14 | 15 | function Exec 16 | { 17 | [CmdletBinding()] 18 | param( 19 | [Parameter(Position=0,Mandatory=1)][scriptblock]$cmd, 20 | [Parameter(Position=1,Mandatory=0)][string]$errorMessage = ($msgs.error_bad_command -f $cmd) 21 | ) 22 | & $cmd 23 | if ($lastexitcode -ne 0) { 24 | throw ("Exec: " + $errorMessage) 25 | } 26 | } 27 | 28 | $outputDir = "../../output"; 29 | Push-Location src\SimpleEventStore 30 | 31 | Write-Stage "Building solution" 32 | Exec { dotnet build -c $Configuration } 33 | 34 | Write-Stage "Running tests" 35 | $env:Uri = $Uri 36 | $env:AuthKey = $AuthKey 37 | $env:ConsistencyLevel = $ConsistencyLevel 38 | 39 | Exec { dotnet test SimpleEventStore.Tests -c $Configuration --no-build --logger trx } 40 | Exec { dotnet test SimpleEventStore.CosmosDb.Tests -c $Configuration --no-build --logger trx } 41 | 42 | Pop-Location -------------------------------------------------------------------------------- /src/SimpleEventStore/Directory.Build.props: -------------------------------------------------------------------------------- 1 |  2 | 3 | 8 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreAppending.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using NUnit.Framework; 3 | using SimpleEventStore.Tests; 4 | 5 | namespace SimpleEventStore.CosmosDb.Tests 6 | { 7 | [TestFixture] 8 | public class AzureCosmosDbEventStoreAppending : EventStoreAppending 9 | { 10 | protected override Task CreateStorageEngine() 11 | { 12 | return CosmosDbStorageEngineFactory.Create("AppendingTests"); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreAppendingWithConverters.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Newtonsoft.Json; 6 | using Newtonsoft.Json.Converters; 7 | using NUnit.Framework; 8 | using SimpleEventStore.Tests; 9 | using SimpleEventStore.Tests.Events; 10 | 11 | namespace SimpleEventStore.CosmosDb.Tests 12 | { 13 | [TestFixture] 14 | public class AzureCosmosDbEventStoreAppendingWithConverters : EventStoreTestBase 15 | { 16 | [Test] 17 | public async Task when_appending_an_event_that_requires_a_converter_the_event_is_saved_and_read() 18 | { 19 | var streamId = Guid.NewGuid().ToString(); 20 | var @event = new EventData(Guid.NewGuid(), new OrderProcessed(streamId, new Version(1, 2, 0))); 21 | 22 | await Subject.AppendToStream(streamId, 0, @event); 23 | 24 | var stream = await Subject.ReadStreamForwards(streamId); 25 | Assert.That(stream.Count, Is.EqualTo(1)); 26 | Assert.That(stream.Single().StreamId, Is.EqualTo(streamId)); 27 | Assert.That(stream.Single().EventId, Is.EqualTo(@event.EventId)); 28 | Assert.That(stream.Single().EventNumber, Is.EqualTo(1)); 29 | } 30 | 31 | protected override Task CreateStorageEngine() 32 | { 33 | return CosmosDbStorageEngineFactory.Create("JsonSerializationSettingsTests", 34 | settings: new JsonSerializerSettings 35 | { 36 | Converters = new List 37 | { 38 | new VersionConverter() 39 | } 40 | }); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreInitializing.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Azure.Cosmos; 4 | using NUnit.Framework; 5 | 6 | namespace SimpleEventStore.CosmosDb.Tests 7 | { 8 | [TestFixture] 9 | public class AzureCosmosV3EventStoreInitializing 10 | { 11 | private const string DatabaseName = "EventStoreTests-Initialize-CosmosV3"; 12 | private readonly CosmosClient client = CosmosClientFactory.Create(); 13 | 14 | [TearDown] 15 | public Task TearDownDatabase() 16 | { 17 | return client.GetDatabase(DatabaseName).DeleteAsync(); 18 | } 19 | 20 | [Test] 21 | public async Task when_initializing_all_expected_resources_are_created() 22 | { 23 | var collectionName = "AllExpectedResourcesAreCreated_" + Guid.NewGuid(); 24 | await InitialiseStorageEngine(collectionName, collectionThroughput: TestConstants.RequestUnits); 25 | 26 | var database = client.GetDatabase(DatabaseName); 27 | var collection = database.GetContainer(collectionName); 28 | 29 | var collectionResponse = await collection.ReadContainerAsync(); 30 | var collectionProperties = collectionResponse.Resource; 31 | 32 | var offer = await collection.ReadThroughputAsync(); 33 | 34 | Assert.That(offer, Is.EqualTo(TestConstants.RequestUnits)); 35 | Assert.That(collectionProperties.DefaultTimeToLive, Is.Null); 36 | Assert.That(collectionProperties.PartitionKeyPath, Is.EqualTo("/streamId")); 37 | Assert.That(collectionProperties.IndexingPolicy.IncludedPaths.Count, Is.EqualTo(1)); 38 | Assert.That(collectionProperties.IndexingPolicy.IncludedPaths[0].Path, Is.EqualTo("/*")); 39 | Assert.That(collectionProperties.IndexingPolicy.ExcludedPaths.Count, Is.EqualTo(3)); 40 | Assert.That(collectionProperties.IndexingPolicy.ExcludedPaths[0].Path, Is.EqualTo("/body/*")); 41 | Assert.That(collectionProperties.IndexingPolicy.ExcludedPaths[1].Path, Is.EqualTo("/metadata/*")); 42 | } 43 | 44 | [Test] 45 | public async Task when_using_shared_throughput_it_is_set_at_a_database_level() 46 | { 47 | const int dbThroughput = 800; 48 | var collectionName = "SharedCollection_" + Guid.NewGuid(); 49 | 50 | await InitialiseStorageEngine(collectionName, dbThroughput: dbThroughput); 51 | 52 | Assert.That(dbThroughput, Is.EqualTo(await GetDatabaseThroughput())); 53 | Assert.That(await GetCollectionThroughput(collectionName), Is.Null); 54 | } 55 | 56 | [TestCase(60)] 57 | [TestCase(10)] 58 | [TestCase(90)] 59 | public async Task when_initializing_with_a_time_to_live_it_is_set(int ttl) 60 | { 61 | var collectionName = "TimeToLiveIsSet_" + Guid.NewGuid(); 62 | var storageEngine = await CosmosDbStorageEngineFactory.Create(collectionName, DatabaseName, 63 | x => 64 | { 65 | x.UseCollection(o => o.DefaultTimeToLive = ttl); 66 | }); 67 | 68 | var collection = await client.GetContainer(DatabaseName, collectionName).ReadContainerAsync(); 69 | 70 | var collectionProperties = collection.Resource; 71 | 72 | Assert.That(collectionProperties.DefaultTimeToLive, Is.EqualTo(ttl)); 73 | } 74 | 75 | 76 | [Test] 77 | public async Task when_throughput_is_set_offer_is_updated() 78 | { 79 | var dbThroughput = 800; 80 | var collectionThroughput = 400; 81 | var collectionName = "UpdateThroughput_" + Guid.NewGuid(); 82 | 83 | await InitialiseStorageEngine(collectionName, collectionThroughput, dbThroughput); 84 | 85 | Assert.That(dbThroughput, Is.EqualTo(await GetDatabaseThroughput())); 86 | Assert.That(collectionThroughput, Is.EqualTo(await GetCollectionThroughput(collectionName))); 87 | 88 | dbThroughput = 1600; 89 | collectionThroughput = 800; 90 | 91 | await InitialiseStorageEngine(collectionName, collectionThroughput, dbThroughput); 92 | 93 | Assert.That(dbThroughput, Is.EqualTo(await GetDatabaseThroughput())); 94 | Assert.That(collectionThroughput, Is.EqualTo(await GetCollectionThroughput(collectionName))); 95 | } 96 | 97 | [TestCase(null, null, null, 400)] 98 | [TestCase(600, null, 600, null)] 99 | [TestCase(null, 600, null, 600)] 100 | [TestCase(600, 600, 600, 600)] 101 | [TestCase(600, 1000, 600, 1000)] 102 | [TestCase(1000, 600, 1000, 600)] 103 | public async Task set_database_and_collection_throughput_when_database_has_not_been_created(int? dbThroughput, int? collectionThroughput, int? expectedDbThroughput, int? expectedCollectionThroughput) 104 | { 105 | var collectionName = "CollectionThroughput_" + Guid.NewGuid(); 106 | 107 | await InitialiseStorageEngine(collectionName, collectionThroughput, dbThroughput); 108 | 109 | Assert.That(expectedDbThroughput, Is.EqualTo(await GetDatabaseThroughput())); 110 | Assert.That(expectedCollectionThroughput, Is.EqualTo(await GetCollectionThroughput(collectionName))); 111 | } 112 | 113 | 114 | [TestCase(null, 500, null)] 115 | [TestCase(1000, 500, 1000)] 116 | public async Task set_database_and_collection_throughput_when_database_has_already_been_created(int? collectionThroughput, int? expectedDbThroughput, int? expectedCollectionThroughput) 117 | { 118 | const int existingDbThroughput = 500; 119 | await CreateDatabase(existingDbThroughput); 120 | var collectionName = "CollectionThroughput_" + Guid.NewGuid(); 121 | 122 | await InitialiseStorageEngine(collectionName, collectionThroughput, null); 123 | 124 | Assert.That(expectedDbThroughput, Is.EqualTo(await GetDatabaseThroughput())); 125 | Assert.That(expectedCollectionThroughput, Is.EqualTo(await GetCollectionThroughput(collectionName))); 126 | } 127 | 128 | private static async Task InitialiseStorageEngine(string collectionName, int? collectionThroughput = null, 129 | int? dbThroughput = null) 130 | { 131 | var storageEngine = await CosmosDbStorageEngineFactory.Create(collectionName, DatabaseName, x => { 132 | x.UseCollection(o => o.CollectionRequestUnits = collectionThroughput); 133 | x.UseDatabase(o => o.DatabaseRequestUnits = dbThroughput); 134 | }); 135 | 136 | return await storageEngine.Initialise(); 137 | } 138 | 139 | public Task GetCollectionThroughput(string collectionName) 140 | { 141 | var collection = client.GetContainer(DatabaseName, collectionName); 142 | return collection.ReadThroughputAsync(); 143 | } 144 | 145 | public Task GetDatabaseThroughput() 146 | { 147 | return client.GetDatabase(DatabaseName).ReadThroughputAsync(); 148 | } 149 | 150 | private Task CreateDatabase(int databaseRequestUnits) 151 | { 152 | return client.CreateDatabaseIfNotExistsAsync(DatabaseName, databaseRequestUnits); 153 | } 154 | 155 | } 156 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreLogging.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using NUnit.Framework; 4 | using SimpleEventStore.Tests.Events; 5 | 6 | namespace SimpleEventStore.CosmosDb.Tests 7 | { 8 | [TestFixture] 9 | public class AzureCosmosDbEventStoreLogging 10 | { 11 | [Test] 12 | public async Task when_a_write_operation_is_successful_the_log_callback_is_called() 13 | { 14 | ResponseInformation response = null; 15 | var sut = new EventStore(await CreateStorageEngine(t => response = t)); 16 | var streamId = Guid.NewGuid().ToString(); 17 | 18 | await sut.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated("TEST-ORDER"))); 19 | 20 | Assert.That(response, Is.Not.Null); 21 | await TestContext.Out.WriteLineAsync($"Charge: {response.RequestCharge}"); 22 | await TestContext.Out.WriteLineAsync($"Quota Usage: {response.CurrentResourceQuotaUsage}"); 23 | await TestContext.Out.WriteLineAsync($"Max Resource Quote: {response.MaxResourceQuota}"); 24 | await TestContext.Out.WriteLineAsync($"Response headers: {response.ResponseHeaders}"); 25 | } 26 | 27 | [Test] 28 | public async Task when_a_read_operation_is_successful_the_log_callback_is_called() 29 | { 30 | var logCount = 0; 31 | var sut = new EventStore(await CreateStorageEngine(t => logCount++)); 32 | var streamId = Guid.NewGuid().ToString(); 33 | 34 | await sut.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated("TEST-ORDER"))); 35 | await sut.ReadStreamForwards(streamId); 36 | 37 | Assert.That(logCount, Is.EqualTo(2)); 38 | } 39 | 40 | private static Task CreateStorageEngine(Action onSuccessCallback, string collectionName = "LoggingTests") 41 | { 42 | return CosmosDbStorageEngineFactory.Create("LoggingTests", builderOverrides: x => x.UseLogging(o => o.Success = onSuccessCallback)); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreReading.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using NUnit.Framework; 3 | using SimpleEventStore.Tests; 4 | 5 | namespace SimpleEventStore.CosmosDb.Tests 6 | { 7 | [TestFixture] 8 | public class AzureCosmosDbEventStoreReading : EventStoreReading 9 | { 10 | protected override Task CreateStorageEngine() 11 | { 12 | return CosmosDbStorageEngineFactory.Create("ReadingTests"); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbEventStoreReadingPartiallyDeletedStreams.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Microsoft.Azure.Cosmos; 5 | using NUnit.Framework; 6 | using SimpleEventStore.Tests.Events; 7 | 8 | namespace SimpleEventStore.CosmosDb.Tests 9 | { 10 | [TestFixture] 11 | public class AzureCosmosDbEventStoreReadingPartiallyDeletedStreams 12 | { 13 | [Test] 14 | public async Task when_reading_a_stream_that_has_deleted_events_the_stream_can_still_be_read() 15 | { 16 | const string collectionName = "ReadingPartialStreamTests"; 17 | 18 | var client = CosmosClientFactory.Create(); 19 | var storageEngine = await CosmosDbStorageEngineFactory.Create(collectionName); 20 | var eventStore = new EventStore(storageEngine); 21 | var streamId = Guid.NewGuid().ToString(); 22 | await eventStore.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)), new EventData(Guid.NewGuid(), new OrderDispatched(streamId))); 23 | await SimulateTimeToLiveExpiration(CosmosDbStorageEngineFactory.DefaultDatabaseName, collectionName, client, streamId); 24 | 25 | var stream = await eventStore.ReadStreamForwards(streamId); 26 | 27 | Assert.That(stream.Count, Is.EqualTo(1)); 28 | Assert.That(stream.First().EventBody, Is.InstanceOf()); 29 | Assert.That(stream.First().EventNumber, Is.EqualTo(2)); 30 | } 31 | 32 | private static Task SimulateTimeToLiveExpiration(string databaseName, string collectionName, CosmosClient client, string streamId) 33 | { 34 | var collection = client.GetContainer(databaseName, collectionName); 35 | return collection.DeleteItemAsync( 36 | $"{streamId}:1", 37 | new PartitionKey(streamId)); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/AzureCosmosDbStorageEngineBuilderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Azure.Cosmos; 3 | using NUnit.Framework; 4 | 5 | namespace SimpleEventStore.CosmosDb.Tests 6 | { 7 | [TestFixture] 8 | public class AzureCosmosDbStorageEngineBuilderTests 9 | { 10 | [Test] 11 | public void when_creating_an_instance_the_document_client_must_be_supplied() 12 | { 13 | Assert.Throws(() => new AzureCosmosDbStorageEngineBuilder(null, "Test")); 14 | } 15 | 16 | [Test] 17 | public void when_creating_an_instance_the_database_name_must_be_supplied() 18 | { 19 | Assert.Throws(() => new AzureCosmosDbStorageEngineBuilder(CreateClient(), null)); 20 | } 21 | 22 | [Test] 23 | public void when_setting_collection_settings_a_callback_must_be_supplied() 24 | { 25 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test"); 26 | Assert.Throws(() => builder.UseCollection(null)); 27 | } 28 | 29 | [Test] 30 | public void when_setting_subscription_settings_a_callback_must_be_supplied() 31 | { 32 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test"); 33 | Assert.Throws(() => builder.UseCollection(null)); 34 | } 35 | 36 | [Test] 37 | public void when_setting_logging_settings_a_callback_must_be_supplied() 38 | { 39 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test"); 40 | Assert.Throws(() => builder.UseLogging(null)); 41 | } 42 | 43 | [Test] 44 | public void when_setting_the_type_map_it_must_be_supplied() 45 | { 46 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test"); 47 | Assert.Throws(() => builder.UseTypeMap(null)); 48 | } 49 | 50 | [Test] 51 | public void when_setting_the_jsonserializationsettings_it_must_be_supplied() 52 | { 53 | var builder = new AzureCosmosDbStorageEngineBuilder(CreateClient(), "Test"); 54 | Assert.Throws(() => builder.UseJsonSerializerSettings(null)); 55 | } 56 | 57 | private static CosmosClient CreateClient() 58 | { 59 | return new CosmosClient("https://localhost:8081/", "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/ConfigurableTypeMapSerializationBinderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using NUnit.Framework; 4 | using SimpleEventStore.Tests.Events; 5 | 6 | namespace SimpleEventStore.CosmosDb.Tests 7 | { 8 | [TestFixture] 9 | public class ConfigurableTypeMapSerializationBinderTests 10 | { 11 | [Test] 12 | public void when_registering_a_type_with_a_null_event_type_then_an_exception_is_thrown() 13 | { 14 | var sut = new ConfigurableSerializationTypeMap(); 15 | Assert.Throws(() => sut.RegisterType(null, typeof(OrderCreated))); 16 | } 17 | 18 | [Test] 19 | public void when_registering_a_type_with_a_null_type_then_an_exception_is_thrown() 20 | { 21 | var sut = new ConfigurableSerializationTypeMap(); 22 | Assert.Throws(() => sut.RegisterType("TEST", null)); 23 | } 24 | 25 | [Test] 26 | public void when_registering_types_with_a_null_assembly_then_an_exception_is_thrown() 27 | { 28 | var sut = new ConfigurableSerializationTypeMap(); 29 | Assert.Throws(() => sut.RegisterTypes(null, t => true, t => t.Name)); 30 | } 31 | 32 | [Test] 33 | public void when_registering_events_with_a_null_match_function_then_an_exception_is_thrown() 34 | { 35 | var sut = new ConfigurableSerializationTypeMap(); 36 | Assert.Throws(() => sut.RegisterTypes(typeof(OrderCreated).GetTypeInfo().Assembly, null, t => t.Name)); 37 | } 38 | 39 | [Test] 40 | public void when_registering_types_with_a_null_naming_function_then_an_exception_is_thrown() 41 | { 42 | var sut = new ConfigurableSerializationTypeMap(); 43 | Assert.Throws(() => sut.RegisterTypes(typeof(OrderCreated).GetTypeInfo().Assembly, t => true, null)); 44 | } 45 | 46 | [Test] 47 | public void when_registering_a_type_then_the_type_can_be_found() 48 | { 49 | var sut = new ConfigurableSerializationTypeMap(); 50 | sut.RegisterType("OrderCreated", typeof(OrderCreated)); 51 | 52 | Assert.That(sut.GetTypeFromName("OrderCreated"), Is.EqualTo(typeof(OrderCreated))); 53 | } 54 | 55 | [Test] 56 | public void when_registering_multiple_types_then_the_type_can_be_found() 57 | { 58 | var sut = new ConfigurableSerializationTypeMap(); 59 | sut.RegisterTypes(typeof(OrderCreated).GetTypeInfo().Assembly, t => t.Namespace != null && t.Namespace.EndsWith("Events"), t => t.Name); 60 | 61 | Assert.That(sut.GetTypeFromName("OrderCreated"), Is.EqualTo(typeof(OrderCreated))); 62 | } 63 | 64 | [Test] 65 | public void when_registering_a_type_then_the_name_can_be_found() 66 | { 67 | var sut = new ConfigurableSerializationTypeMap(); 68 | sut.RegisterType("OrderCreated", typeof(OrderCreated)); 69 | 70 | Assert.That(sut.GetNameFromType(typeof(OrderCreated)), Is.EqualTo("OrderCreated")); 71 | } 72 | 73 | [Test] 74 | public void when_registering_multiple_types_if_no_types_are_found_then_an_exception_is_thrown() 75 | { 76 | var sut = new ConfigurableSerializationTypeMap(); 77 | Assert.Throws(() => sut.RegisterTypes(typeof(OrderCreated).GetTypeInfo().Assembly, t => false, t => t.Name)); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/CosmosClientFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Cosmos; 2 | using Microsoft.Azure.Cosmos.Fluent; 3 | using Microsoft.Extensions.Configuration; 4 | using Newtonsoft.Json; 5 | 6 | namespace SimpleEventStore.CosmosDb.Tests 7 | { 8 | internal static class CosmosClientFactory 9 | { 10 | internal static CosmosClient Create() 11 | { 12 | return Create(new JsonSerializerSettings()); 13 | } 14 | 15 | internal static CosmosClient Create(JsonSerializerSettings serializationOptions) 16 | { 17 | var config = new ConfigurationBuilder() 18 | .AddJsonFile("appsettings.json") 19 | .AddEnvironmentVariables() 20 | .Build(); 21 | 22 | var documentDbUri = config["Uri"]; 23 | var authKey = config["AuthKey"]; 24 | 25 | return new CosmosClientBuilder(documentDbUri, authKey) 26 | .WithCustomSerializer(new CosmosJsonNetSerializer(serializationOptions)) 27 | .Build(); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/CosmosDbStorageEngineFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using System.Threading.Tasks; 4 | using Microsoft.Azure.Cosmos; 5 | using Microsoft.Extensions.Configuration; 6 | using Newtonsoft.Json; 7 | using SimpleEventStore.Tests.Events; 8 | 9 | namespace SimpleEventStore.CosmosDb.Tests 10 | { 11 | internal static class CosmosDbStorageEngineFactory 12 | { 13 | public const string DefaultDatabaseName = "EventStoreTests"; 14 | 15 | internal static Task Create(string collectionName, string databaseName = null, Action builderOverrides = null, JsonSerializerSettings settings = null) 16 | { 17 | settings = settings ?? new JsonSerializerSettings(); 18 | 19 | databaseName = databaseName ?? DefaultDatabaseName; 20 | 21 | var config = new ConfigurationBuilder() 22 | .AddJsonFile("appsettings.json") 23 | .AddEnvironmentVariables() 24 | .Build(); 25 | 26 | var consistencyLevel = config["ConsistencyLevel"]; 27 | 28 | if (!Enum.TryParse(consistencyLevel, true, out ConsistencyLevel consistencyLevelEnum)) 29 | { 30 | throw new Exception($"The ConsistencyLevel value {consistencyLevel} is not supported"); 31 | } 32 | 33 | var client = CosmosClientFactory.Create(settings); 34 | 35 | var builder = new AzureCosmosDbStorageEngineBuilder(client, databaseName) 36 | .UseDatabase(o => 37 | { 38 | o.DatabaseRequestUnits = TestConstants.RequestUnits; 39 | }) 40 | .UseCollection(o => 41 | { 42 | o.CollectionName = collectionName; 43 | o.ConsistencyLevel = consistencyLevelEnum; 44 | o.CollectionRequestUnits = null; 45 | }) 46 | .UseTypeMap(new ConfigurableSerializationTypeMap() 47 | .RegisterTypes( 48 | typeof(OrderCreated).GetTypeInfo().Assembly, 49 | t => t.Namespace != null && t.Namespace.EndsWith("Events"), 50 | t => t.Name)) 51 | .UseJsonSerializerSettings(settings); 52 | 53 | builderOverrides?.Invoke(builder); 54 | 55 | return builder.Build().Initialise(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/CosmosDbStorageEventTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using Newtonsoft.Json; 4 | using Newtonsoft.Json.Linq; 5 | using SimpleEventStore.Tests.Events; 6 | using NUnit.Framework; 7 | 8 | namespace SimpleEventStore.CosmosDb.Tests 9 | { 10 | [TestFixture] 11 | public class DocumentDbStorageEventTests 12 | { 13 | [Test] 14 | public void when_converting_to_a_storage_event_it_succeeds() 15 | { 16 | var id = Guid.NewGuid(); 17 | var body = new OrderCreated("TEST-ORDER"); 18 | var metadata = new TestMetadata { Value = "TEST-VALUE" }; 19 | var sut = new CosmosDbStorageEvent 20 | { 21 | StreamId = "TEST-STREAM", 22 | Body = JObject.FromObject(body), 23 | BodyType = "OrderCreated", 24 | Metadata = JObject.FromObject(metadata), 25 | MetadataType = "TestMetadata", 26 | EventNumber = 1, 27 | EventId = id 28 | }; 29 | var typeMap = new ConfigurableSerializationTypeMap().RegisterTypes( 30 | typeof(OrderCreated).GetTypeInfo().Assembly, 31 | t => t.Namespace != null && t.Namespace.EndsWith("Events"), 32 | t => t.Name); 33 | var result = sut.ToStorageEvent(typeMap, JsonSerializer.CreateDefault()); 34 | 35 | Assert.That(result.StreamId, Is.EqualTo(sut.StreamId)); 36 | Assert.That(((OrderCreated)result.EventBody).OrderId, Is.EqualTo(body.OrderId)); 37 | Assert.That(((TestMetadata)result.Metadata).Value, Is.EqualTo(metadata.Value)); 38 | Assert.That(result.EventNumber, Is.EqualTo(sut.EventNumber)); 39 | Assert.That(result.EventId, Is.EqualTo(sut.EventId)); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/PerformanceTests.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleEventStore.CosmosDb.Tests 2 | { 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Collections.Generic; 6 | using System.Diagnostics; 7 | using System.Linq; 8 | using System.Threading.Tasks; 9 | using Microsoft.Azure.Cosmos; 10 | using NUnit.Framework; 11 | using SimpleEventStore.Tests; 12 | using SimpleEventStore.Tests.Events; 13 | 14 | [Ignore("Not used in CI/CD pipeline")] 15 | public class PerformanceTests : EventStoreTestBase 16 | { 17 | private const string DatabaseName = "PerformanceTests"; 18 | private readonly CosmosClient client = CosmosClientFactory.Create(); 19 | 20 | [OneTimeTearDown] 21 | public async Task TearDownDatabase() 22 | { 23 | await client.GetDatabase(DatabaseName).DeleteAsync(); 24 | } 25 | 26 | protected override Task CreateStorageEngine() 27 | { 28 | return CosmosDbStorageEngineFactory.Create(collectionName: "PerformanceTests", databaseName: DatabaseName, builder => builder.UseDatabase(options => options.DatabaseRequestUnits = 100000)); 29 | } 30 | 31 | private static readonly IEnumerable NumberOfClients = Enumerable.Range(10, 100).Where(i => i % 25 == 0); 32 | 33 | [Test] 34 | public async Task append_as_quickly_as_possible([ValueSource(nameof(NumberOfClients))] int clients) 35 | { 36 | const int streamsPerClient = 40; 37 | const int eventsPerStream = 2; 38 | 39 | var streams = new ConcurrentBag(); 40 | 41 | async Task CreateStreams() 42 | { 43 | for (var i = 0; i < streamsPerClient; i++) 44 | { 45 | var streamId = Guid.NewGuid().ToString(); 46 | 47 | await this.Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId))); 48 | await this.Subject.AppendToStream(streamId, 1, new EventData(Guid.NewGuid(), new OrderDispatched(streamId))); 49 | 50 | streams.Add(streamId); 51 | } 52 | } 53 | 54 | var streamCreatingClients = new List(); 55 | for (var i = 0; i < clients; i++) 56 | { 57 | streamCreatingClients.Add(CreateStreams()); 58 | } 59 | 60 | var stopWatch = Stopwatch.StartNew(); 61 | await Task.WhenAll(streamCreatingClients); 62 | stopWatch.Stop(); 63 | var timeTaken = stopWatch.Elapsed; 64 | 65 | var writesPerSecond = (clients * streamsPerClient * eventsPerStream) / timeTaken.TotalSeconds; 66 | 67 | Console.WriteLine($"Clients = {clients}"); 68 | Console.WriteLine($"Streams per client = {streamsPerClient}"); 69 | Console.WriteLine($"Total events written = {clients * streamsPerClient * eventsPerStream}"); 70 | Console.WriteLine($"Writes per second = {writesPerSecond}"); 71 | 72 | Assert.That(streams.Count, Is.EqualTo(clients * streamsPerClient)); 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/ResponseInformationBuilding.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Specialized; 4 | using System.Net; 5 | using Microsoft.Azure.Cosmos; 6 | using Microsoft.Azure.Cosmos.Scripts; 7 | using NUnit.Framework; 8 | 9 | namespace SimpleEventStore.CosmosDb.Tests 10 | { 11 | [TestFixture] 12 | public class ResponseInformationBuilding 13 | { 14 | [Test] 15 | public void when_building_from_a_write_response_all_target_fields_are_mapped() 16 | { 17 | var result = ResponseInformation.FromWriteResponse(Expected.RequestIdentifier, new FakeStoredProcedureResponse()); 18 | 19 | Assert.That(result.RequestIdentifier, Is.EqualTo(Expected.RequestIdentifier)); 20 | Assert.That(result.RequestCharge, Is.EqualTo(Expected.RequestCharge)); 21 | Assert.That(result.ResponseHeaders, Is.EqualTo(Expected.ResponseHeaders)); 22 | } 23 | 24 | [Test] 25 | public void when_building_from_a_read_response_all_target_fields_are_mapped() 26 | { 27 | var result = ResponseInformation.FromReadResponse(Expected.RequestIdentifier, new FakeFeedResponse()); 28 | 29 | Assert.That(result.RequestIdentifier, Is.EqualTo(Expected.RequestIdentifier)); 30 | Assert.That(result.CurrentResourceQuotaUsage, Is.EqualTo(Expected.CurrentResourceQuotaUsage)); 31 | Assert.That(result.MaxResourceQuota, Is.EqualTo(Expected.MaxResourceQuota)); 32 | Assert.That(result.RequestCharge, Is.EqualTo(Expected.RequestCharge)); 33 | Assert.That(result.ResponseHeaders, Is.EqualTo(Expected.ResponseHeaders)); 34 | } 35 | 36 | [Test] 37 | public void when_building_from_a_subscription_read_response_all_target_fields_are_mapped() 38 | { 39 | var result = ResponseInformation.FromSubscriptionReadResponse(Expected.RequestIdentifier, new FakeFeedResponse()); 40 | 41 | Assert.That(result.RequestIdentifier, Is.EqualTo(Expected.RequestIdentifier)); 42 | Assert.That(result.CurrentResourceQuotaUsage, Is.EqualTo(Expected.CurrentResourceQuotaUsage)); 43 | Assert.That(result.MaxResourceQuota, Is.EqualTo(Expected.MaxResourceQuota)); 44 | Assert.That(result.RequestCharge, Is.EqualTo(Expected.RequestCharge)); 45 | Assert.That(result.ResponseHeaders, Is.EqualTo(Expected.ResponseHeaders)); 46 | } 47 | 48 | private static class Expected 49 | { 50 | internal const string RequestIdentifier = "TEST-Identifier"; 51 | internal const string CurrentResourceQuotaUsage = "TEST-CurrentResourceQuotaUsage"; 52 | internal const string MaxResourceQuota = "TEST-MaxResourceQuota"; 53 | internal const double RequestCharge = 100d; 54 | internal static NameValueCollection ResponseHeaders = new NameValueCollection(); 55 | 56 | static Expected() 57 | { 58 | ResponseHeaders.Add("Location", ""); 59 | ResponseHeaders.Add("Session", ""); 60 | ResponseHeaders.Add("RequestCharge", "0"); 61 | ResponseHeaders.Add("ActivityId", ""); 62 | ResponseHeaders.Add("ContentLength", ""); 63 | ResponseHeaders.Add("ContentType", ""); 64 | ResponseHeaders.Add("ContinuationToken", ""); 65 | ResponseHeaders.Add("ETag", ""); 66 | } 67 | } 68 | 69 | private class FakeStoredProcedureResponse : StoredProcedureExecuteResponse 70 | { 71 | internal FakeStoredProcedureResponse() 72 | { 73 | RequestCharge = Expected.RequestCharge; 74 | ResponseHeaders = Expected.ResponseHeaders; 75 | } 76 | 77 | public override string ActivityId { get; } 78 | 79 | public override double RequestCharge { get; } 80 | 81 | public TValue Response { get; } 82 | 83 | public NameValueCollection ResponseHeaders { get; } 84 | 85 | public override string SessionToken { get; } 86 | 87 | public override string ScriptLog { get; } 88 | 89 | public override HttpStatusCode StatusCode { get; } 90 | } 91 | 92 | private class FakeFeedResponse : FeedResponse 93 | { 94 | internal FakeFeedResponse() 95 | { 96 | RequestCharge = Expected.RequestCharge; 97 | ResponseHeaders = Expected.ResponseHeaders; 98 | Headers = new Headers 99 | { 100 | {"x-ms-resource-quota", Expected.MaxResourceQuota}, 101 | {"x-ms-resource-usage", Expected.CurrentResourceQuotaUsage} 102 | }; 103 | } 104 | 105 | public long DatabaseQuota { get; } 106 | 107 | public long DatabaseUsage { get; } 108 | 109 | public long CollectionQuota { get; } 110 | 111 | public long CollectionUsage { get; } 112 | 113 | public long UserQuota { get; } 114 | 115 | public long UserUsage { get; } 116 | 117 | public long PermissionQuota { get; } 118 | 119 | public long PermissionUsage { get; } 120 | 121 | public long CollectionSizeQuota { get; } 122 | 123 | public long CollectionSizeUsage { get; } 124 | 125 | public long StoredProceduresQuota { get; } 126 | 127 | public long StoredProceduresUsage { get; } 128 | 129 | public long TriggersQuota { get; } 130 | 131 | public long TriggersUsage { get; } 132 | 133 | public long UserDefinedFunctionsQuota { get; } 134 | 135 | public long UserDefinedFunctionsUsage { get; } 136 | 137 | public override string ContinuationToken { get; } 138 | public override int Count { get; } 139 | 140 | public override Headers Headers { get; } 141 | public override IEnumerable Resource { get; } 142 | public override HttpStatusCode StatusCode { get; } 143 | public override double RequestCharge { get; } 144 | 145 | public override string ActivityId { get; } 146 | public override CosmosDiagnostics Diagnostics { get; } 147 | 148 | public string ResponseContinuation { get; } 149 | 150 | public string SessionToken { get; } 151 | 152 | public string ContentLocation { get; } 153 | 154 | public NameValueCollection ResponseHeaders { get; } 155 | 156 | public override string IndexMetrics => string.Empty; 157 | 158 | public override IEnumerator GetEnumerator() 159 | { 160 | yield return Activator.CreateInstance(); 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/SimpleEventStore.CosmosDb.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | SimpleEventStore.CosmosDb.Tests 6 | 7 | 8 | 9 | Always 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | SimpleEventStore.CosmosDb.Tests 27 | ASOS 28 | Copyright ASOS © 2024 29 | SimpleEventStore.CosmosDb.Tests 30 | 31 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/TestConstants.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleEventStore.CosmosDb.Tests 2 | { 3 | internal static class TestConstants 4 | { 5 | internal const int RequestUnits = 700; 6 | } 7 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Uri": "https://localhost:8081/", 3 | "AuthKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", 4 | "ConsistencyLevel": "Session" 5 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/AzureCosmosDbStorageEngine.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.Azure.Cosmos; 7 | using Newtonsoft.Json; 8 | 9 | namespace SimpleEventStore.CosmosDb 10 | { 11 | internal class AzureCosmosDbStorageEngine : IStorageEngine 12 | { 13 | private readonly CosmosClient _client; 14 | private readonly string _databaseName; 15 | private readonly CollectionOptions collectionOptions; 16 | private readonly LoggingOptions _loggingOptions; 17 | private readonly ISerializationTypeMap _typeMap; 18 | private readonly JsonSerializer _jsonSerializer; 19 | private readonly DatabaseOptions _databaseOptions; 20 | private Database _database; 21 | private Container _collection; 22 | 23 | internal AzureCosmosDbStorageEngine(CosmosClient client, string databaseName, 24 | CollectionOptions collectionOptions, DatabaseOptions databaseOptions, LoggingOptions loggingOptions, 25 | ISerializationTypeMap typeMap, JsonSerializer serializer) 26 | { 27 | _client = client; 28 | _databaseName = databaseName; 29 | _databaseOptions = databaseOptions; 30 | this.collectionOptions = collectionOptions; 31 | _loggingOptions = loggingOptions; 32 | _typeMap = typeMap; 33 | _jsonSerializer = serializer; 34 | } 35 | 36 | public async Task Initialise(CancellationToken cancellationToken = default) 37 | { 38 | cancellationToken.ThrowIfCancellationRequested(); 39 | var databaseResponse = await CreateDatabaseIfItDoesNotExist(); 40 | _database = databaseResponse.Database; 41 | 42 | cancellationToken.ThrowIfCancellationRequested(); 43 | var containerResponse = (await CreateCollectionIfItDoesNotExist()); 44 | _collection = containerResponse.Container; 45 | 46 | cancellationToken.ThrowIfCancellationRequested(); 47 | await Task.WhenAll( 48 | SetDatabaseOfferThroughput(), 49 | SetCollectionOfferThroughput() 50 | ); 51 | 52 | return this; 53 | } 54 | 55 | public async Task AppendToStream(string streamId, IEnumerable events, 56 | CancellationToken cancellationToken = default) 57 | { 58 | var storageEvents = events.ToList(); 59 | var firstEventNumber = storageEvents.First().EventNumber; 60 | 61 | try 62 | { 63 | var transactionalBatchItemRequestOptions = new TransactionalBatchItemRequestOptions 64 | { 65 | EnableContentResponseOnWrite = false 66 | }; 67 | 68 | var batch = storageEvents.Aggregate( 69 | _collection.CreateTransactionalBatch(new PartitionKey(streamId)), 70 | (b, e) => b.CreateItem(CosmosDbStorageEvent.FromStorageEvent(e, _typeMap, _jsonSerializer), transactionalBatchItemRequestOptions)); 71 | 72 | var batchResponse = firstEventNumber == 1 ? 73 | await CreateEvents(batch, cancellationToken) : 74 | await CreateEventsOnlyIfPreviousEventExists(batch, streamId, firstEventNumber - 1, cancellationToken); 75 | 76 | _loggingOptions.OnSuccess(ResponseInformation.FromWriteResponse(nameof(AppendToStream), batchResponse)); 77 | } 78 | catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict|| ex.Headers["x-ms-substatus"] == "409" || ex.SubStatusCode == 409) 79 | { 80 | throw new ConcurrencyException( 81 | $"Concurrency conflict when appending to stream {streamId}. Expected revision {firstEventNumber - 1}", 82 | ex); 83 | } 84 | } 85 | 86 | private static async Task CreateEvents( 87 | TransactionalBatch batch, CancellationToken cancellationToken) 88 | { 89 | using var batchResponse = await batch.ExecuteAsync(cancellationToken); 90 | 91 | return batchResponse.IsSuccessStatusCode 92 | ? batchResponse 93 | : throw new CosmosException(batchResponse.ErrorMessage, batchResponse.StatusCode, 0, 94 | batchResponse.ActivityId, batchResponse.RequestCharge); 95 | } 96 | 97 | private static async Task CreateEventsOnlyIfPreviousEventExists( 98 | TransactionalBatch batch, string streamId, int previousEventNumber, CancellationToken cancellationToken) 99 | { 100 | batch.ReadItem(streamId + $":{previousEventNumber}", new TransactionalBatchItemRequestOptions { EnableContentResponseOnWrite = true }); 101 | using var batchResponse = await batch.ExecuteAsync(cancellationToken); 102 | 103 | return batchResponse.IsSuccessStatusCode 104 | ? batchResponse 105 | : throw batchResponse.StatusCode switch 106 | { 107 | HttpStatusCode.NotFound => new CosmosException( 108 | $"Previous Event {previousEventNumber} not found for stream '{streamId}'", 109 | HttpStatusCode.Conflict, 0, batchResponse.ActivityId, batchResponse.RequestCharge), 110 | _ => new CosmosException(batchResponse.ErrorMessage, batchResponse.StatusCode, 0, 111 | batchResponse.ActivityId, batchResponse.RequestCharge) 112 | }; 113 | } 114 | 115 | public async Task> ReadStreamForwards(string streamId, int startPosition, 116 | int numberOfEventsToRead, CancellationToken cancellationToken = default) 117 | { 118 | int endPosition = numberOfEventsToRead == int.MaxValue 119 | ? int.MaxValue 120 | : startPosition + numberOfEventsToRead - 1; 121 | 122 | var queryDefinition = new QueryDefinition(@" 123 | SELECT VALUE e 124 | FROM e 125 | WHERE e.streamId = @StreamId 126 | AND (e.eventNumber BETWEEN @LowerBound AND @UpperBound) 127 | ORDER BY e.eventNumber ASC" 128 | ) 129 | .WithParameter("@StreamId", streamId) 130 | .WithParameter("@LowerBound", startPosition) 131 | .WithParameter("@UpperBound", endPosition); 132 | 133 | var options = new QueryRequestOptions 134 | { 135 | MaxItemCount = numberOfEventsToRead, 136 | PartitionKey = new PartitionKey(streamId) 137 | }; 138 | 139 | using var eventsQuery = _collection.GetItemQueryIterator(queryDefinition, requestOptions: options); 140 | var events = new List(); 141 | 142 | while (eventsQuery.HasMoreResults) 143 | { 144 | var response = await eventsQuery.ReadNextAsync(cancellationToken); 145 | _loggingOptions.OnSuccess(ResponseInformation.FromReadResponse(nameof(ReadStreamForwards), response)); 146 | 147 | foreach (var e in response) 148 | { 149 | events.Add(e.ToStorageEvent(_typeMap, _jsonSerializer)); 150 | } 151 | } 152 | 153 | return events.AsReadOnly(); 154 | } 155 | 156 | private Task CreateDatabaseIfItDoesNotExist() 157 | { 158 | return _client.CreateDatabaseIfNotExistsAsync(_databaseName, _databaseOptions.DatabaseRequestUnits); 159 | } 160 | 161 | private Task CreateCollectionIfItDoesNotExist() 162 | { 163 | var collectionProperties = new ContainerProperties() 164 | { 165 | Id = collectionOptions.CollectionName, 166 | IndexingPolicy = new IndexingPolicy 167 | { 168 | IncludedPaths = 169 | { 170 | new IncludedPath {Path = "/*"}, 171 | }, 172 | ExcludedPaths = 173 | { 174 | new ExcludedPath {Path = "/body/*"}, 175 | new ExcludedPath {Path = "/metadata/*"} 176 | } 177 | }, 178 | DefaultTimeToLive = collectionOptions.DefaultTimeToLive, 179 | PartitionKeyPath = "/streamId" 180 | }; 181 | 182 | return _database.CreateContainerIfNotExistsAsync(collectionProperties, 183 | collectionOptions.CollectionRequestUnits); 184 | } 185 | 186 | private async Task SetCollectionOfferThroughput() 187 | { 188 | if (collectionOptions.CollectionRequestUnits != null) 189 | { 190 | await _collection.ReplaceThroughputAsync((int) collectionOptions.CollectionRequestUnits); 191 | } 192 | } 193 | 194 | private async Task SetDatabaseOfferThroughput() 195 | { 196 | if (_databaseOptions.DatabaseRequestUnits != null) 197 | { 198 | await _database.ReplaceThroughputAsync((int) _databaseOptions.DatabaseRequestUnits); 199 | } 200 | } 201 | } 202 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/AzureCosmosDbStorageEngineBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Azure.Cosmos; 3 | using Newtonsoft.Json; 4 | 5 | namespace SimpleEventStore.CosmosDb 6 | { 7 | public class AzureCosmosDbStorageEngineBuilder 8 | { 9 | private readonly string _databaseName; 10 | private readonly CosmosClient _client; 11 | private readonly CollectionOptions collectionOptions = new CollectionOptions(); 12 | private readonly DatabaseOptions _databaseOptions = new DatabaseOptions(); 13 | private readonly LoggingOptions _loggingOptions = new LoggingOptions(); 14 | private ISerializationTypeMap _typeMap = new DefaultSerializationTypeMap(); 15 | private JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings(); 16 | 17 | public AzureCosmosDbStorageEngineBuilder(CosmosClient client, string databaseName) 18 | { 19 | Guard.IsNotNull(nameof(client), client); 20 | Guard.IsNotNullOrEmpty(nameof(databaseName), databaseName); 21 | 22 | _client = client; 23 | _databaseName = databaseName; 24 | } 25 | 26 | public AzureCosmosDbStorageEngineBuilder UseCollection(Action action) 27 | { 28 | Guard.IsNotNull(nameof(action), action); 29 | 30 | action(collectionOptions); 31 | return this; 32 | } 33 | 34 | public AzureCosmosDbStorageEngineBuilder UseLogging(Action action) 35 | { 36 | Guard.IsNotNull(nameof(action), action); 37 | 38 | action(_loggingOptions); 39 | return this; 40 | } 41 | 42 | public AzureCosmosDbStorageEngineBuilder UseTypeMap(ISerializationTypeMap typeMap) 43 | { 44 | Guard.IsNotNull(nameof(typeMap), typeMap); 45 | _typeMap = typeMap; 46 | 47 | return this; 48 | } 49 | 50 | public AzureCosmosDbStorageEngineBuilder UseJsonSerializerSettings(JsonSerializerSettings settings) 51 | { 52 | Guard.IsNotNull(nameof(settings), settings); 53 | _jsonSerializerSettings = settings; 54 | return this; 55 | } 56 | 57 | public AzureCosmosDbStorageEngineBuilder UseDatabase(Action action) 58 | { 59 | Guard.IsNotNull(nameof(action), action); 60 | 61 | action(_databaseOptions); 62 | return this; 63 | } 64 | 65 | public IStorageEngine Build() 66 | { 67 | return new AzureCosmosDbStorageEngine(_client, 68 | _databaseName, 69 | collectionOptions, 70 | _databaseOptions, 71 | _loggingOptions, 72 | _typeMap, 73 | JsonSerializer.Create(_jsonSerializerSettings)); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/CollectionOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Cosmos; 2 | 3 | namespace SimpleEventStore.CosmosDb 4 | { 5 | public class CollectionOptions 6 | { 7 | public CollectionOptions() 8 | { 9 | ConsistencyLevel = ConsistencyLevel.Session; 10 | CollectionRequestUnits = 400; 11 | CollectionName = "Commits"; 12 | } 13 | 14 | public string CollectionName { get; set; } 15 | 16 | public ConsistencyLevel ConsistencyLevel { get; set; } 17 | 18 | public int? CollectionRequestUnits { get; set; } 19 | 20 | public int? DefaultTimeToLive { get; set; } 21 | } 22 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/ConfigurableSerializationTypeMap.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | 6 | namespace SimpleEventStore.CosmosDb 7 | { 8 | public class ConfigurableSerializationTypeMap : ISerializationTypeMap 9 | { 10 | private readonly Dictionary typeMap = new Dictionary(); 11 | private readonly Dictionary nameMap = new Dictionary(); 12 | 13 | public ConfigurableSerializationTypeMap RegisterType(string eventType, Type type) 14 | { 15 | Guard.IsNotNullOrEmpty(nameof(eventType), eventType); 16 | Guard.IsNotNull(nameof(type), type); 17 | 18 | typeMap.Add(eventType, type); 19 | nameMap.Add(type, eventType); 20 | return this; 21 | } 22 | 23 | public ConfigurableSerializationTypeMap RegisterTypes(Assembly assembly, Func matchFunc, Func namingFunc) 24 | { 25 | Guard.IsNotNull(nameof(assembly), assembly); 26 | Guard.IsNotNull(nameof(matchFunc), matchFunc); 27 | Guard.IsNotNull(nameof(namingFunc), namingFunc); 28 | bool matchesFound = false; 29 | 30 | foreach (var type in assembly.GetTypes().Where(matchFunc)) 31 | { 32 | matchesFound = true; 33 | RegisterType(namingFunc(type), type); 34 | } 35 | 36 | if (!matchesFound) 37 | { 38 | throw new NoTypesFoundException("The matchFunc matched no types in the assembly"); 39 | } 40 | 41 | return this; 42 | } 43 | 44 | public Type GetTypeFromName(string typeName) 45 | { 46 | return typeMap[typeName]; 47 | } 48 | 49 | public string GetNameFromType(Type type) 50 | { 51 | return nameMap[type]; 52 | } 53 | } 54 | 55 | public class NoTypesFoundException : Exception 56 | { 57 | public NoTypesFoundException(string message) : base(message) 58 | { } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/CosmosDbStorageEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Azure.Cosmos; 3 | using Newtonsoft.Json; 4 | using Newtonsoft.Json.Linq; 5 | 6 | namespace SimpleEventStore.CosmosDb 7 | { 8 | public class CosmosDbStorageEvent 9 | { 10 | [JsonProperty("id")] 11 | public string Id { get; set; } 12 | 13 | [JsonProperty("eventId")] 14 | public Guid EventId { get; set; } 15 | 16 | [JsonProperty("body")] 17 | public JObject Body { get; set; } 18 | 19 | [JsonProperty("bodyType")] 20 | public string BodyType { get; set; } 21 | 22 | [JsonProperty("metadata")] 23 | public JObject Metadata { get; set; } 24 | 25 | [JsonProperty("metadataType")] 26 | public string MetadataType { get; set; } 27 | 28 | [JsonProperty("streamId")] 29 | public string StreamId { get; set; } 30 | 31 | [JsonProperty("eventNumber")] 32 | public int EventNumber { get; set; } 33 | 34 | public static CosmosDbStorageEvent FromStorageEvent(StorageEvent @event, ISerializationTypeMap typeMap, JsonSerializer serializer) 35 | { 36 | var docDbEvent = new CosmosDbStorageEvent(); 37 | docDbEvent.Id = $"{@event.StreamId}:{@event.EventNumber}"; 38 | docDbEvent.EventId = @event.EventId; 39 | docDbEvent.Body = JObject.FromObject(@event.EventBody, serializer); 40 | docDbEvent.BodyType = typeMap.GetNameFromType(@event.EventBody.GetType()); 41 | if (@event.Metadata != null) 42 | { 43 | docDbEvent.Metadata = JObject.FromObject(@event.Metadata, serializer); 44 | docDbEvent.MetadataType = typeMap.GetNameFromType(@event.Metadata.GetType()); 45 | } 46 | docDbEvent.StreamId = @event.StreamId; 47 | docDbEvent.EventNumber = @event.EventNumber; 48 | 49 | return docDbEvent; 50 | } 51 | 52 | public static CosmosDbStorageEvent FromDocument(ItemResponse document) 53 | { 54 | return document.Resource; 55 | } 56 | 57 | public StorageEvent ToStorageEvent(ISerializationTypeMap typeMap, JsonSerializer serializer) 58 | { 59 | object body = Body.ToObject(typeMap.GetTypeFromName(BodyType), serializer); 60 | object metadata = Metadata?.ToObject(typeMap.GetTypeFromName(MetadataType), serializer); 61 | return new StorageEvent(StreamId, new EventData(EventId, body, metadata), EventNumber); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/DatabaseOptions.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleEventStore.CosmosDb 2 | { 3 | public class DatabaseOptions 4 | { 5 | public int? DatabaseRequestUnits {get; set;} 6 | } 7 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/DefaultSerializationTypeMap.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SimpleEventStore.CosmosDb 4 | { 5 | public class DefaultSerializationTypeMap : ISerializationTypeMap 6 | { 7 | public string GetNameFromType(Type type) 8 | { 9 | return type.AssemblyQualifiedName; 10 | } 11 | 12 | public Type GetTypeFromName(string typeName) 13 | { 14 | return Type.GetType(typeName); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/ISerializationTypeMap.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SimpleEventStore.CosmosDb 4 | { 5 | public interface ISerializationTypeMap 6 | { 7 | Type GetTypeFromName(string typeName); 8 | 9 | string GetNameFromType(Type type); 10 | } 11 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/JsonNetCosmosSerializer.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Text; 3 | using Microsoft.Azure.Cosmos; 4 | using Newtonsoft.Json; 5 | 6 | namespace SimpleEventStore.CosmosDb 7 | { 8 | public class CosmosJsonNetSerializer : CosmosSerializer 9 | { 10 | private static readonly Encoding DefaultEncoding = new UTF8Encoding(false, true); 11 | public JsonSerializer JsonSerializer { get; } 12 | private readonly JsonSerializerSettings serializerSettings; 13 | 14 | public CosmosJsonNetSerializer() : this(new JsonSerializerSettings()) 15 | { 16 | } 17 | 18 | public CosmosJsonNetSerializer(JsonSerializerSettings serializerSettings) 19 | { 20 | this.serializerSettings = serializerSettings; 21 | JsonSerializer = JsonSerializer.Create(this.serializerSettings); 22 | } 23 | 24 | public override T FromStream(Stream stream) 25 | { 26 | using (stream) 27 | { 28 | if (typeof(Stream).IsAssignableFrom(typeof(T))) 29 | { 30 | return (T)(object) stream; 31 | } 32 | 33 | using (StreamReader sr = new StreamReader(stream)) 34 | { 35 | using (JsonTextReader jsonTextReader = new JsonTextReader(sr)) 36 | { 37 | return JsonSerializer.Deserialize(jsonTextReader); 38 | } 39 | } 40 | } 41 | } 42 | 43 | public override Stream ToStream(T input) 44 | { 45 | MemoryStream streamPayload = new MemoryStream(); 46 | using (StreamWriter streamWriter = new StreamWriter(streamPayload, encoding: DefaultEncoding, bufferSize: 1024, leaveOpen: true)) 47 | { 48 | using (JsonWriter writer = new JsonTextWriter(streamWriter)) 49 | { 50 | writer.Formatting = Formatting.None; 51 | JsonSerializer.Serialize(writer, input); 52 | writer.Flush(); 53 | streamWriter.Flush(); 54 | } 55 | } 56 | 57 | streamPayload.Position = 0; 58 | return streamPayload; 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/LoggingOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SimpleEventStore.CosmosDb 4 | { 5 | public class LoggingOptions 6 | { 7 | public Action Success { get; set; } 8 | 9 | internal void OnSuccess(ResponseInformation response) 10 | { 11 | Success?.Invoke(response); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/Resources.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Reflection; 3 | 4 | namespace SimpleEventStore.CosmosDb 5 | { 6 | internal static class Resources 7 | { 8 | public static string GetString(string resourceName) 9 | { 10 | using (var reader = new StreamReader(GetStream(resourceName))) 11 | { 12 | return reader.ReadToEnd(); 13 | } 14 | } 15 | 16 | private static Stream GetStream(string resourceName) 17 | { 18 | resourceName = $"{typeof(Resources).FullName}.{resourceName}"; 19 | return typeof(Resources).GetTypeInfo().Assembly.GetManifestResourceStream(resourceName); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/ResponseInformation.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | using System.Globalization; 3 | using Microsoft.Azure.Cosmos; 4 | using Microsoft.Azure.Cosmos.Scripts; 5 | 6 | namespace SimpleEventStore.CosmosDb 7 | { 8 | public class ResponseInformation 9 | { 10 | public string RequestIdentifier { get; private set; } 11 | 12 | public string CurrentResourceQuotaUsage { get; private set; } 13 | 14 | public string MaxResourceQuota { get; private set; } 15 | 16 | public double RequestCharge { get; private set; } 17 | 18 | public NameValueCollection ResponseHeaders { get; private set; } 19 | 20 | public static ResponseInformation FromWriteResponse(string requestIdentifier, StoredProcedureExecuteResponse response) 21 | { 22 | return new ResponseInformation 23 | { 24 | RequestIdentifier = requestIdentifier, 25 | CurrentResourceQuotaUsage = GetCurrentResourceQuotaUsage(response), 26 | MaxResourceQuota = GetMaxResourceQuota(response), 27 | RequestCharge = response.RequestCharge, 28 | ResponseHeaders = HeaderToNamedValueCollection(response.Headers) 29 | }; 30 | } 31 | 32 | public static ResponseInformation FromWriteResponse(string requestIdentifier, TransactionalBatchResponse response) 33 | { 34 | return new ResponseInformation 35 | { 36 | RequestIdentifier = requestIdentifier, 37 | CurrentResourceQuotaUsage = GetCurrentResourceQuotaUsage(response), 38 | RequestCharge = response.RequestCharge 39 | }; 40 | } 41 | 42 | private static string GetCurrentResourceQuotaUsage(TransactionalBatchResponse response) 43 | { 44 | return response.RequestCharge.ToString(); 45 | } 46 | 47 | private static string GetCurrentResourceQuotaUsage(Response response) 48 | { 49 | return response.Headers?.GetValueOrDefault("x-ms-resource-usage"); 50 | } 51 | 52 | private static string GetMaxResourceQuota(Response response) 53 | { 54 | return response.Headers?.GetValueOrDefault("x-ms-resource-quota"); 55 | } 56 | 57 | public static ResponseInformation FromReadResponse(string requestIdentifier, FeedResponse response) 58 | { 59 | return new ResponseInformation 60 | { 61 | RequestIdentifier = requestIdentifier, 62 | CurrentResourceQuotaUsage = GetCurrentResourceQuotaUsage(response), 63 | MaxResourceQuota = GetMaxResourceQuota(response), 64 | RequestCharge = response.RequestCharge, 65 | ResponseHeaders = HeaderToNamedValueCollection(response.Headers) 66 | }; 67 | } 68 | 69 | public static ResponseInformation FromSubscriptionReadResponse(string requestIdentifier, Response response) 70 | { 71 | return new ResponseInformation 72 | { 73 | RequestIdentifier = requestIdentifier, 74 | CurrentResourceQuotaUsage = GetCurrentResourceQuotaUsage(response), 75 | MaxResourceQuota = GetMaxResourceQuota(response), 76 | RequestCharge = response.RequestCharge, 77 | ResponseHeaders = HeaderToNamedValueCollection(response.Headers) 78 | }; 79 | } 80 | 81 | private static NameValueCollection HeaderToNamedValueCollection(Headers headers) 82 | { 83 | return new NameValueCollection() 84 | { 85 | {"Location", headers?.Location}, 86 | {"Session", headers?.Session}, 87 | {"RequestCharge", headers?.RequestCharge.ToString(CultureInfo.InvariantCulture)}, 88 | {"ActivityId", headers?.ActivityId}, 89 | {"ContentLength", headers?.ContentLength}, 90 | {"ContentType", headers?.ContentType}, 91 | {"ContinuationToken", headers?.ContinuationToken}, 92 | {"ETag", headers?.ETag} 93 | }; 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/SimpleEventStore.AzureDocumentDb.nuspec: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Asos.SimpleEventStore.AzureDocumentDb 5 | $version$ 6 | $author$ 7 | $author$ 8 | https://github.com/ASOS/SimpleEventStore 9 | false 10 | $description$ 11 | eventsourcing documentdb azure 12 | 13 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.CosmosDb/SimpleEventStore.CosmosDb.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0 5 | 1.0.0 6 | SimpleEventStore.CosmosDb 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Asos.SimpleEventStore.CosmosDb 16 | Uses Azure Cosmos DB as the stream database for Simple Event Store (SES) 17 | ASOS 18 | Copyright ASOS © 2024 19 | SimpleEventStore.CosmosDb 20 | Asos.SimpleEventStore.CosmosDb 21 | ASOS 22 | eventsourcing azure cosmosdb 23 | https://github.com/ASOS/SimpleEventStore 24 | 25 | library 26 | $(BuildVersion) 27 | $(BuildVersion) 28 | 29 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/EventDataTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using NUnit.Framework; 3 | 4 | namespace SimpleEventStore.Tests 5 | { 6 | [TestFixture] 7 | public class EventDataTests 8 | { 9 | [Test] 10 | public void when_creating_an_instance_the_event_body_must_be_supplied() 11 | { 12 | Assert.Throws(() => new EventData(Guid.NewGuid(), null)); 13 | } 14 | 15 | [Test] 16 | public void when_creating_an_instance_the_properties_are_mapped() 17 | { 18 | var eventId = Guid.NewGuid(); 19 | var sut = new EventData(eventId, "BODY", "METADATA"); 20 | 21 | Assert.That(sut.EventId, Is.EqualTo(eventId)); 22 | Assert.That(sut.Body, Is.EqualTo("BODY")); 23 | Assert.That(sut.Metadata, Is.EqualTo("METADATA")); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/EventStoreAppending.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using NUnit.Framework; 6 | using SimpleEventStore.Tests.Events; 7 | 8 | namespace SimpleEventStore.Tests 9 | { 10 | [TestFixture] 11 | public abstract class EventStoreAppending : EventStoreTestBase 12 | { 13 | [Test] 14 | public async Task when_appending_to_a_new_stream_the_event_is_saved() 15 | { 16 | var streamId = Guid.NewGuid().ToString(); 17 | var @event = new EventData(Guid.NewGuid(), new OrderCreated(streamId)); 18 | 19 | await Subject.AppendToStream(streamId, 0, @event); 20 | 21 | var stream = await Subject.ReadStreamForwards(streamId); 22 | Assert.That(stream.Count, Is.EqualTo(1)); 23 | Assert.That(stream.Single().StreamId, Is.EqualTo(streamId)); 24 | Assert.That(stream.Single().EventId, Is.EqualTo(@event.EventId)); 25 | Assert.That(stream.Single().EventNumber, Is.EqualTo(1)); 26 | } 27 | 28 | [Test] 29 | public async Task when_appending_to_an_existing_stream_the_event_is_saved() 30 | { 31 | var streamId = Guid.NewGuid().ToString(); 32 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId))); 33 | var @event = new EventData(Guid.NewGuid(), new OrderDispatched(streamId)); 34 | 35 | await Subject.AppendToStream(streamId, 1, @event); 36 | 37 | var stream = await Subject.ReadStreamForwards(streamId); 38 | Assert.That(stream.Count, Is.EqualTo(2)); 39 | Assert.That(stream.Skip(1).Single().EventId, Is.EqualTo(@event.EventId)); 40 | Assert.That(stream.Skip(1).Single().EventNumber, Is.EqualTo(2)); 41 | } 42 | 43 | [Test] 44 | [TestCase(-1)] 45 | [TestCase(1)] 46 | public void when_appending_to_a_new_stream_with_an_unexpected_version__a_concurrency_error_is_thrown(int expectedVersion) 47 | { 48 | var streamId = Guid.NewGuid().ToString(); 49 | var @event = new EventData(Guid.NewGuid(), new OrderDispatched(streamId)); 50 | 51 | var exception = Assert.ThrowsAsync(async () => await Subject.AppendToStream(streamId, expectedVersion, @event)); 52 | Assert.That(exception.Message, Is.EqualTo($"Concurrency conflict when appending to stream {streamId}. Expected revision {expectedVersion}")); 53 | } 54 | 55 | [Test] 56 | [TestCase(0)] 57 | [TestCase(2)] 58 | public async Task when_appending_to_an_existing_stream_with_an_unexpected_version_a_concurrency_error_is_thrown(int expectedVersion) 59 | { 60 | var streamId = Guid.NewGuid().ToString(); 61 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId))); 62 | 63 | var @event = new EventData(Guid.NewGuid(), new OrderDispatched(streamId)); 64 | 65 | var exception = Assert.ThrowsAsync(async () => await Subject.AppendToStream(streamId, expectedVersion, @event)); 66 | Assert.That(exception.Message, Is.EqualTo($"Concurrency conflict when appending to stream {streamId}. Expected revision {expectedVersion}")); 67 | } 68 | 69 | [Test] 70 | [TestCase(null)] 71 | [TestCase("")] 72 | [TestCase(" ")] 73 | public void when_appending_to_an_invalid_stream_id_an_argument_error_is_thrown(string streamId) 74 | { 75 | Assert.ThrowsAsync(async () => await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId)))); 76 | } 77 | 78 | [Test] 79 | public async Task when_appending_to_a_new_stream_with_multiple_events_then_they_are_saved() 80 | { 81 | var streamId = Guid.NewGuid().ToString(); 82 | var events = new [] 83 | { 84 | new EventData(Guid.NewGuid(), new OrderCreated(streamId)), 85 | new EventData(Guid.NewGuid(), new OrderDispatched(streamId)) 86 | }; 87 | 88 | await Subject.AppendToStream(streamId, 0, events); 89 | 90 | var savedEvents = await Subject.ReadStreamForwards(streamId); 91 | 92 | Assert.That(savedEvents.Count, Is.EqualTo(2)); 93 | Assert.That(savedEvents.First().StreamId, Is.EqualTo(streamId)); 94 | Assert.That(savedEvents.First().EventNumber, Is.EqualTo(1)); 95 | Assert.That(savedEvents.Skip(1).Single().StreamId, Is.EqualTo(streamId)); 96 | Assert.That(savedEvents.Skip(1).Single().EventNumber, Is.EqualTo(2)); 97 | } 98 | 99 | [Test] 100 | public async Task when_appending_to_a_new_stream_the_event_metadata_is_saved() 101 | { 102 | var streamId = Guid.NewGuid().ToString(); 103 | var metadata = new TestMetadata { Value = "Hello" }; 104 | var @event = new EventData(Guid.NewGuid(), new OrderCreated(streamId), metadata); 105 | 106 | await Subject.AppendToStream(streamId, 0, @event); 107 | 108 | var stream = await Subject.ReadStreamForwards(streamId); 109 | Assert.That(((TestMetadata)stream.Single().Metadata).Value, Is.EqualTo(metadata.Value)); 110 | } 111 | 112 | [Test] 113 | public void when_appending_to_a_stream_the_engine_honours_cancellation_token() 114 | { 115 | var streamId = Guid.NewGuid().ToString(); 116 | var metadata = new TestMetadata { Value = "Hello" }; 117 | var @event = new EventData(Guid.NewGuid(), new OrderCreated(streamId), metadata); 118 | 119 | using (var cts = new CancellationTokenSource()) 120 | { 121 | cts.Cancel(); 122 | 123 | AsyncTestDelegate act = () => Subject.AppendToStream(streamId, 0, cts.Token, @event); 124 | 125 | Assert.That(act, Throws.InstanceOf()); 126 | } 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/EventStoreReading.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using SimpleEventStore.Tests.Events; 3 | using System; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace SimpleEventStore.Tests 9 | { 10 | // TODOs 11 | // 1. Make partioning support configurable 12 | // 2. Allow for lower levels of consistency than just strong 13 | [TestFixture] 14 | public abstract class EventStoreReading : EventStoreTestBase 15 | { 16 | [Test] 17 | public async Task when_reading_a_stream_which_has_no_events_an_empty_list_is_returned() 18 | { 19 | var streamId = Guid.NewGuid().ToString(); 20 | 21 | var events = await Subject.ReadStreamForwards(streamId); 22 | 23 | Assert.That(events.Count, Is.EqualTo(0)); 24 | } 25 | 26 | [Test] 27 | public async Task when_reading_a_stream_all_events_are_returned() 28 | { 29 | var streamId = Guid.NewGuid().ToString(); 30 | 31 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId))); 32 | await Subject.AppendToStream(streamId, 1, new EventData(Guid.NewGuid(), new OrderDispatched(streamId))); 33 | 34 | var events = await Subject.ReadStreamForwards(streamId); 35 | 36 | Assert.That(events.Count, Is.EqualTo(2)); 37 | Assert.That(events.First().EventBody, Is.InstanceOf()); 38 | Assert.That(events.Skip(1).Single().EventBody, Is.InstanceOf()); 39 | } 40 | 41 | [Test] 42 | public async Task when_reading_a_stream_events_returned_in_correct_order() 43 | { 44 | var streamId = Guid.NewGuid().ToString(); 45 | 46 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId))); 47 | await Subject.AppendToStream(streamId, 1, new EventData(Guid.NewGuid(), new OrderDispatched(streamId))); 48 | await Subject.AppendToStream(streamId, 2, new EventData(Guid.NewGuid(), new OrderDispatched(streamId))); 49 | 50 | var events = await Subject.ReadStreamForwards(streamId); 51 | 52 | Assert.That(events.Count, Is.EqualTo(3)); 53 | Assert.That(events.First().EventNumber, Is.EqualTo(1)); 54 | Assert.That(events.Skip(1).First().EventNumber, Is.EqualTo(2)); 55 | Assert.That(events.Skip(2).First().EventNumber, Is.EqualTo(3)); 56 | } 57 | 58 | [Test] 59 | [TestCase(null)] 60 | [TestCase("")] 61 | [TestCase(" ")] 62 | public void when_reading_from_an_invalid_stream_id_an_argument_error_is_thrown(string streamId) 63 | { 64 | Assert.ThrowsAsync(async () => await Subject.ReadStreamForwards(streamId)); 65 | } 66 | 67 | [Test] 68 | public async Task when_reading_a_stream_only_the_required_events_are_returned() 69 | { 70 | var streamId = Guid.NewGuid().ToString(); 71 | 72 | await Subject.AppendToStream(streamId, 0, new EventData(Guid.NewGuid(), new OrderCreated(streamId))); 73 | await Subject.AppendToStream(streamId, 1, new EventData(Guid.NewGuid(), new OrderDispatched(streamId))); 74 | await Subject.AppendToStream(streamId, 2, new EventData(Guid.NewGuid(), new OrderProcessed(streamId, default))); 75 | 76 | var events = await Subject.ReadStreamForwards(streamId, startPosition: 2, numberOfEventsToRead: 1); 77 | 78 | Assert.That(events.Count, Is.EqualTo(1)); 79 | Assert.That(events.First().EventBody, Is.InstanceOf()); 80 | } 81 | 82 | [Test] 83 | public void when_reading_a_stream_the_engine_honours_cancellation_token() 84 | { 85 | var streamId = Guid.NewGuid().ToString(); 86 | 87 | using (var cts = new CancellationTokenSource()) 88 | { 89 | cts.Cancel(); 90 | 91 | AsyncTestDelegate act = () => Subject.ReadStreamForwards(streamId, cts.Token); 92 | 93 | Assert.That(act, Throws.InstanceOf()); 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/EventStoreTestBase.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using System.Threading.Tasks; 3 | 4 | namespace SimpleEventStore.Tests 5 | { 6 | public abstract class EventStoreTestBase 7 | { 8 | protected EventStore Subject { get; private set; } 9 | 10 | [OneTimeSetUp] 11 | public async Task OneTimeSetUp() 12 | { 13 | var storageEngine = await CreateStorageEngine(); 14 | Subject = new EventStore(storageEngine); 15 | } 16 | 17 | 18 | protected abstract Task CreateStorageEngine(); 19 | } 20 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/Events/OrderCreated.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleEventStore.Tests.Events 2 | { 3 | public class OrderCreated 4 | { 5 | public string OrderId { get; private set; } 6 | 7 | public OrderCreated(string orderId) 8 | { 9 | OrderId = orderId; 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/Events/OrderDispatched.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleEventStore.Tests.Events 2 | { 3 | public class OrderDispatched 4 | { 5 | public string OrderId { get; private set; } 6 | 7 | public OrderDispatched(string orderId) 8 | { 9 | OrderId = orderId; 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/Events/OrderProcessed.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SimpleEventStore.Tests.Events 4 | { 5 | public class OrderProcessed 6 | { 7 | public string OrderId { get; private set; } 8 | 9 | public Version OrderProcessorVersion { get; set; } 10 | 11 | public OrderProcessed(string orderId, Version orderProcessorVersion) 12 | { 13 | OrderId = orderId; 14 | OrderProcessorVersion = orderProcessorVersion; 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/Events/TestMetadata.cs: -------------------------------------------------------------------------------- 1 | namespace SimpleEventStore.Tests.Events 2 | { 3 | public class TestMetadata 4 | { 5 | public string Value { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/InMemory/InMemoryEventStoreAppending.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using NUnit.Framework; 3 | using SimpleEventStore.InMemory; 4 | 5 | namespace SimpleEventStore.Tests.InMemory 6 | { 7 | [TestFixture] 8 | public class InMemoryEventStoreAppending : EventStoreAppending 9 | { 10 | protected override Task CreateStorageEngine() 11 | { 12 | return Task.FromResult((IStorageEngine)new InMemoryStorageEngine()); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/InMemory/InMemoryEventStoreReading.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using NUnit.Framework; 3 | using SimpleEventStore.InMemory; 4 | 5 | namespace SimpleEventStore.Tests.InMemory 6 | { 7 | [TestFixture] 8 | public class InMemoryEventStoreReading : EventStoreReading 9 | { 10 | protected override Task CreateStorageEngine() 11 | { 12 | return Task.FromResult((IStorageEngine)new InMemoryStorageEngine()); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/SimpleEventStore.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | SimpleEventStore.Tests 16 | ASOS 17 | Copyright ASOS © 2024 18 | SimpleEventStore.Tests 19 | 20 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.Tests/StorageEventTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using NUnit.Framework; 3 | 4 | namespace SimpleEventStore.Tests 5 | { 6 | [TestFixture] 7 | public class StorageEventTests 8 | { 9 | [Test] 10 | public void when_creating_a_new_instance_the_properties_are_mapped() 11 | { 12 | var eventId = Guid.NewGuid(); 13 | var @event = new EventData(eventId, "BODY", "METADATA"); 14 | 15 | var sut = new StorageEvent("STREAMID", @event, 1); 16 | 17 | Assert.That(sut.StreamId, Is.EqualTo("STREAMID")); 18 | Assert.That(sut.EventBody, Is.EqualTo("BODY")); 19 | Assert.That(sut.Metadata, Is.EqualTo("METADATA")); 20 | Assert.That(sut.EventNumber, Is.EqualTo(1)); 21 | Assert.That(sut.EventId, Is.EqualTo(eventId)); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 14 4 | VisualStudioVersion = 14.0.25420.1 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEventStore", "SimpleEventStore\SimpleEventStore.csproj", "{73235465-69BF-4762-B8C5-20C8E45795FF}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{CD241C9A-0A56-42C9-8309-D68890C78B64}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEventStore.Tests", "SimpleEventStore.Tests\SimpleEventStore.Tests.csproj", "{ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEventStore.CosmosDb", "SimpleEventStore.CosmosDb\SimpleEventStore.CosmosDb.csproj", "{48C71940-D9B0-446A-9F3D-E6275CD43440}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleEventStore.CosmosDb.Tests", "SimpleEventStore.CosmosDb.Tests\SimpleEventStore.CosmosDb.Tests.csproj", "{205A7F81-A496-4400-9A97-D156F88B7883}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {73235465-69BF-4762-B8C5-20C8E45795FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {73235465-69BF-4762-B8C5-20C8E45795FF}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {73235465-69BF-4762-B8C5-20C8E45795FF}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {73235465-69BF-4762-B8C5-20C8E45795FF}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {48C71940-D9B0-446A-9F3D-E6275CD43440}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {48C71940-D9B0-446A-9F3D-E6275CD43440}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {48C71940-D9B0-446A-9F3D-E6275CD43440}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {48C71940-D9B0-446A-9F3D-E6275CD43440}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {205A7F81-A496-4400-9A97-D156F88B7883}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {205A7F81-A496-4400-9A97-D156F88B7883}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {205A7F81-A496-4400-9A97-D156F88B7883}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {205A7F81-A496-4400-9A97-D156F88B7883}.Release|Any CPU.Build.0 = Release|Any CPU 38 | EndGlobalSection 39 | GlobalSection(SolutionProperties) = preSolution 40 | HideSolutionNode = FALSE 41 | EndGlobalSection 42 | GlobalSection(NestedProjects) = preSolution 43 | {ACA6B3AE-FCB9-45F4-9D6B-66196F98F819} = {CD241C9A-0A56-42C9-8309-D68890C78B64} 44 | {205A7F81-A496-4400-9A97-D156F88B7883} = {CD241C9A-0A56-42C9-8309-D68890C78B64} 45 | EndGlobalSection 46 | EndGlobal 47 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore/ConcurrencyException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SimpleEventStore 4 | { 5 | public class ConcurrencyException : Exception 6 | { 7 | // 8 | // For guidelines regarding the creation of new exception types, see 9 | // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/cpgenref/html/cpconerrorraisinghandlingguidelines.asp 10 | // and 11 | // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dncscol/html/csharp07192001.asp 12 | // 13 | 14 | public ConcurrencyException() 15 | { 16 | } 17 | 18 | public ConcurrencyException(string message) : base(message) 19 | { 20 | } 21 | 22 | public ConcurrencyException(string message, Exception inner) : base(message, inner) 23 | { 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore/EventData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SimpleEventStore 4 | { 5 | public class EventData 6 | { 7 | public Guid EventId { get; private set; } 8 | 9 | public object Body { get; private set; } 10 | 11 | public object Metadata { get; private set; } 12 | 13 | public EventData(Guid eventId, object body, object metadata = null) 14 | { 15 | Guard.IsNotNull(nameof(body), body); 16 | 17 | EventId = eventId; 18 | Body = body; 19 | Metadata = metadata; 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore/EventStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace SimpleEventStore 7 | { 8 | public class EventStore 9 | { 10 | private readonly IStorageEngine engine; 11 | 12 | public EventStore(IStorageEngine engine) 13 | { 14 | this.engine = engine; 15 | } 16 | 17 | public Task AppendToStream(string streamId, int expectedVersion, params EventData[] events) 18 | { 19 | return AppendToStream(streamId, expectedVersion, default, events); 20 | } 21 | 22 | public Task AppendToStream(string streamId, int expectedVersion, CancellationToken cancellationToken, params EventData[] events) 23 | { 24 | Guard.IsNotNullOrEmpty(nameof(streamId), streamId); 25 | 26 | var storageEvents = new List(); 27 | var eventVersion = expectedVersion; 28 | 29 | for (int i = 0; i < events.Length; i++) 30 | { 31 | storageEvents.Add(new StorageEvent(streamId, events[i], ++eventVersion)); 32 | } 33 | 34 | return engine.AppendToStream(streamId, storageEvents, cancellationToken); 35 | } 36 | 37 | public Task> ReadStreamForwards(string streamId, CancellationToken cancellationToken = default) 38 | { 39 | Guard.IsNotNullOrEmpty(nameof(streamId), streamId); 40 | 41 | return engine.ReadStreamForwards(streamId, 1, Int32.MaxValue, cancellationToken); 42 | } 43 | 44 | public Task> ReadStreamForwards(string streamId, int startPosition, int numberOfEventsToRead, CancellationToken cancellationToken = default) 45 | { 46 | Guard.IsNotNullOrEmpty(nameof(streamId), streamId); 47 | 48 | return engine.ReadStreamForwards(streamId, startPosition, numberOfEventsToRead, cancellationToken); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore/Guard.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SimpleEventStore 4 | { 5 | public static class Guard 6 | { 7 | public static void IsNotNullOrEmpty(string paramName, string value) 8 | { 9 | if (string.IsNullOrWhiteSpace(value)) 10 | { 11 | throw new ArgumentException("The value cannot be a null, empty string or contain only whitespace", paramName); 12 | } 13 | } 14 | 15 | public static void IsNotNull(string paramName, object value) 16 | { 17 | if (value == null) 18 | { 19 | throw new ArgumentNullException(paramName, "The value cannot be null"); 20 | } 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore/IStorageEngine.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace SimpleEventStore 6 | { 7 | public interface IStorageEngine 8 | { 9 | Task AppendToStream(string streamId, IEnumerable events, CancellationToken cancellationToken = default); 10 | 11 | Task> ReadStreamForwards(string streamId, int startPosition, int numberOfEventsToRead, CancellationToken cancellationToken = default); 12 | 13 | Task Initialise(CancellationToken cancellationToken = default); 14 | } 15 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore/InMemory/InMemoryStorageEngine.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace SimpleEventStore.InMemory 8 | { 9 | public class InMemoryStorageEngine : IStorageEngine 10 | { 11 | private static readonly IReadOnlyCollection EmptyStream = new StorageEvent[0]; 12 | 13 | private readonly ConcurrentDictionary> streams = new ConcurrentDictionary>(); 14 | private readonly List allEvents = new List(); 15 | 16 | public Task AppendToStream(string streamId, IEnumerable events, CancellationToken cancellationToken = default) 17 | { 18 | return Task.Run(() => 19 | { 20 | if (!streams.ContainsKey(streamId)) 21 | { 22 | streams[streamId] = new List(); 23 | } 24 | 25 | var firstEvent = events.First(); 26 | 27 | if (firstEvent.EventNumber - 1 != streams[streamId].Count) 28 | { 29 | throw new ConcurrencyException($"Concurrency conflict when appending to stream {streamId}. Expected revision {firstEvent.EventNumber - 1}"); 30 | } 31 | 32 | cancellationToken.ThrowIfCancellationRequested(); 33 | 34 | streams[streamId].AddRange(events); 35 | AddEventsToAllStream(events); 36 | }, cancellationToken); 37 | } 38 | 39 | private void AddEventsToAllStream(IEnumerable events) 40 | { 41 | foreach (var e in events) 42 | { 43 | allEvents.Add(e); 44 | } 45 | } 46 | 47 | public Task> ReadStreamForwards(string streamId, int startPosition, int numberOfEventsToRead, CancellationToken cancellationToken = default) 48 | { 49 | cancellationToken.ThrowIfCancellationRequested(); 50 | 51 | if (!streams.ContainsKey(streamId)) 52 | { 53 | return Task.FromResult(EmptyStream); 54 | } 55 | 56 | IReadOnlyCollection stream = streams[streamId].Skip(startPosition - 1).Take(numberOfEventsToRead).ToList().AsReadOnly(); 57 | return Task.FromResult(stream); 58 | } 59 | 60 | public Task Initialise(CancellationToken cancellationToken = default) 61 | { 62 | cancellationToken.ThrowIfCancellationRequested(); 63 | 64 | return Task.FromResult(this); 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore/SimpleEventStore.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0 5 | 1.0.0 6 | 7 | 8 | ASOS.SimpleEventStore 9 | Simple Event Store (SES) provides a lightweight event sourcing abstraction. 10 | ASOS 11 | Copyright ASOS © 2024 12 | SimpleEventStore 13 | Asos.SimpleEventStore 14 | ASOS 15 | eventsourcing 16 | https://github.com/ASOS/SimpleEventStore 17 | $(BuildVersion) 18 | $(BuildVersion) 19 | 20 | -------------------------------------------------------------------------------- /src/SimpleEventStore/SimpleEventStore/StorageEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SimpleEventStore 4 | { 5 | public class StorageEvent 6 | { 7 | public string StreamId { get; private set; } 8 | 9 | public object EventBody { get; private set; } 10 | 11 | public object Metadata { get; private set; } 12 | 13 | public int EventNumber { get; private set; } 14 | 15 | public Guid EventId { get; private set; } 16 | 17 | public StorageEvent(string streamId, EventData data, int eventNumber) 18 | { 19 | StreamId = streamId; 20 | EventBody = data.Body; 21 | Metadata = data.Metadata; 22 | EventNumber = eventNumber; 23 | EventId = data.EventId; 24 | } 25 | } 26 | } 27 | --------------------------------------------------------------------------------