Add Persistence vNext provider-neutral POC
Adds the Persistence vNext proof of concept, including provider-neutral schema/document abstractions, relational and document provider POCs, Elsa integration, runtime-defined entities, physicalization planning, workflow runtime evaluation, and focused tests.
This commit is contained in:
parent
39e22bea31
commit
c16995014c
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"feature_directory": "specs/010-workflow-json-hardening"
|
||||
"feature_directory": "specs/011-persistence-vnext"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@
|
|||
<PackageVersion Include="Microsoft.Identity.Client" Version="4.79.2"/>
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1"/>
|
||||
<PackageVersion Include="Moq" Version="4.20.72"/>
|
||||
<PackageVersion Include="MongoDB.Driver" Version="3.8.1"/>
|
||||
<PackageVersion Include="MySql.Data" Version="9.5.0"/>
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4"/>
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3"/>
|
||||
|
|
|
|||
160
Elsa.sln
160
Elsa.sln
|
|
@ -1,4 +1,4 @@
|
|||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.7.34003.232
|
||||
|
|
@ -373,6 +373,26 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dashboard.Api", "src\m
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Dashboard.Api.UnitTests", "test\unit\Elsa.Dashboard.Api.UnitTests\Elsa.Dashboard.Api.UnitTests.csproj", "{157EDBA7-B04F-4EA0-8377-434D1065ACF6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext", "src\modules\Elsa.Persistence.VNext\Elsa.Persistence.VNext.csproj", "{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.Sqlite", "src\modules\Elsa.Persistence.VNext.Sqlite\Elsa.Persistence.VNext.Sqlite.csproj", "{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.SqlServer", "src\modules\Elsa.Persistence.VNext.SqlServer\Elsa.Persistence.VNext.SqlServer.csproj", "{274E751E-849D-4C2F-9DC2-7E1690C40A63}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.Persistence.VNext", "src\modules\Elsa.Secrets.Persistence.VNext\Elsa.Secrets.Persistence.VNext.csproj", "{1C024945-B49F-42C1-BFB9-44DE43702584}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.UnitTests", "test\unit\Elsa.Persistence.VNext.UnitTests\Elsa.Persistence.VNext.UnitTests.csproj", "{EA0DCE74-36F1-4892-9EDE-605ED4444948}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.Relational", "src\modules\Elsa.Persistence.VNext.Relational\Elsa.Persistence.VNext.Relational.csproj", "{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.PostgreSql", "src\modules\Elsa.Persistence.VNext.PostgreSql\Elsa.Persistence.VNext.PostgreSql.csproj", "{2D652061-C844-4AF8-B8C0-459189460303}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.MongoDb", "src\modules\Elsa.Persistence.VNext.MongoDb\Elsa.Persistence.VNext.MongoDb.csproj", "{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.Extensions", "src\modules\Elsa.Persistence.VNext.Extensions\Elsa.Persistence.VNext.Extensions.csproj", "{E1607923-038B-41D6-9D23-F540FC9E6CCE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Persistence.VNext.Runtime", "src\modules\Elsa.Persistence.VNext.Runtime\Elsa.Persistence.VNext.Runtime.csproj", "{6E3B6948-B16D-480C-879E-B805F732649F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
|
@ -1557,6 +1577,126 @@ Global
|
|||
{157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{157EDBA7-B04F-4EA0-8377-434D1065ACF6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Release|x64.Build.0 = Release|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948}.Release|x86.Build.0 = Release|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2D652061-C844-4AF8-B8C0-459189460303}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
@ -1680,19 +1820,29 @@ Global
|
|||
{09B4B78B-FE02-44E2-8667-E182AF921C54} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{7D905CEC-B30B-4C99-B5F7-3052D33EC8E9} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
{81CFD2E0-2E5E-4810-ADB8-A08301199166} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
{8F4AD54E-8586-4D8C-82E6-69218DD4280F} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D}
|
||||
{60F844CC-6DA6-45EB-BA5E-B480B4E77624} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
{B6A62D42-D9AA-47BE-BCBC-FCB866D346F0} = {1B8D5897-902E-4632-8698-E89CAF3DDF54}
|
||||
{D3E5E9EF-DE26-41BF-A239-3241B5B5FD89} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{80F7ED38-15BA-4C82-88C8-12135B33F7EF} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{0C6F614C-A698-426B-8D11-0132C2FFF400} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{E18931B2-82EE-4398-B38A-7B8B449957CD} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{729FD075-2863-48AB-A3C0-A4F7A9277263} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{E4B1546A-3F35-4B5C-815C-07E58B00163F} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{4DEE0574-C5B3-4765-9C48-89D384A17780} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{151C46EF-FE3D-400B-8CBE-F86886B3EB96} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{8F4AD54E-8586-4D8C-82E6-69218DD4280F} = {78FD90A4-90A5-445F-97F2-74BA835AFA5D}
|
||||
{E4B1546A-3F35-4B5C-815C-07E58B00163F} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{E18931B2-82EE-4398-B38A-7B8B449957CD} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{729FD075-2863-48AB-A3C0-A4F7A9277263} = {D3E5E9EF-DE26-41BF-A239-3241B5B5FD89}
|
||||
{4FD4C59B-2804-4F6B-AD38-2562CC01C510} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{157EDBA7-B04F-4EA0-8377-434D1065ACF6} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
{97A5CDC6-00C7-4A64-B40D-631B9B03AFF3} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{7EF93679-9788-4B6A-8B53-FF49E08FFC0D} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{274E751E-849D-4C2F-9DC2-7E1690C40A63} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{1C024945-B49F-42C1-BFB9-44DE43702584} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{EA0DCE74-36F1-4892-9EDE-605ED4444948} = {18453B51-25EB-4317-A4B3-B10518252E92}
|
||||
{94C4A253-72A9-4F93-A668-F30E6AAE6BB3} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{2D652061-C844-4AF8-B8C0-459189460303} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{D0F9978C-9F75-48C2-88F6-5B7813D5AC74} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{E1607923-038B-41D6-9D23-F540FC9E6CCE} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
{6E3B6948-B16D-480C-879E-B805F732649F} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}
|
||||
|
|
|
|||
94
design/persistence-vnext-poc.md
Normal file
94
design/persistence-vnext-poc.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Persistence vNext POC
|
||||
|
||||
## Goal
|
||||
|
||||
This proof of concept explores whether Elsa modules can declare persistence intent once and let database providers own physical schema materialization. The immediate target is reducing the maintenance cost of per-module EF Core migrations across SQLite, SQL Server, PostgreSQL, MySQL, and Oracle.
|
||||
|
||||
## Current Pain
|
||||
|
||||
The existing EF Core stack keeps logical module configuration in module packages, while migrations live in provider packages. That works, but every schema change fans out into provider-specific migrations and model snapshots. The Secrets module is a compact example: a single logical `Secrets` table is represented by one EF model configuration and five provider-specific initial migrations.
|
||||
|
||||
## Proposed Model
|
||||
|
||||
The POC introduces four layers:
|
||||
|
||||
1. Module descriptors: modules expose `IPersistenceSchemaProvider` and return versioned provider-neutral storage-unit, field, key, and index descriptors.
|
||||
2. Provider-family planning: a planner turns the same descriptors into a family-specific plan. The POC has relational and document planners.
|
||||
3. Provider rendering/materialization: a provider renders or executes physical operations. The POC implements SQLite SQL rendering/execution and SQL Server render-only output.
|
||||
4. Schema history: a provider records applied descriptor versions so startup materialization can be idempotent.
|
||||
|
||||
The core extension points are intentionally small:
|
||||
|
||||
- `IPersistenceSchemaProvider`
|
||||
- `IPersistenceSchemaPlanner<TPlan>`
|
||||
- `IPersistenceSchemaRenderer<TPlan>`
|
||||
- `IPersistenceSchemaMigrator<TContext>`
|
||||
|
||||
In this model, the module owns storage intent:
|
||||
|
||||
- storage unit name
|
||||
- namespace
|
||||
- schema version
|
||||
- fields
|
||||
- logical types
|
||||
- nullability
|
||||
- lengths
|
||||
- keys
|
||||
- indexes
|
||||
|
||||
The provider owns physical details:
|
||||
|
||||
- whether a storage unit becomes a table, collection, bucket, container, or something else
|
||||
- SQL type names
|
||||
- identifier quoting
|
||||
- whether schemas are supported
|
||||
- document collection/index options
|
||||
- BSON/JSON naming conventions
|
||||
- generated DDL or migration operations
|
||||
- execution strategy
|
||||
- schema history storage
|
||||
|
||||
## POC Scope
|
||||
|
||||
The POC models the Secrets storage unit through `SecretPersistenceSchemaProvider`. It does not replace the current EF Core repository or migrations. Tests prove that the Secrets module can declare storage intent once, SQLite can render and execute a relational table, SQL Server can render provider-specific relational DDL from the same descriptor, a document planner can produce a collection/index plan, and SQLite can record applied schema versions.
|
||||
|
||||
SQLite records versions in `ElsaSchemaVersions`:
|
||||
|
||||
- `SchemaName`
|
||||
- `Version`
|
||||
- `AppliedAt`
|
||||
|
||||
`SqliteSchemaVersionRunner` creates the history table if needed, checks the highest version for the descriptor schema name, executes rendered DDL only when the descriptor version is newer, and records the applied version in the same transaction.
|
||||
|
||||
## Document Databases
|
||||
|
||||
Document database providers should not consume relational table descriptors directly. They should consume the provider-neutral storage-unit descriptors and produce a document plan:
|
||||
|
||||
- collection name
|
||||
- namespace/database/tenant partition where applicable
|
||||
- document fields
|
||||
- identity field
|
||||
- secondary indexes
|
||||
- unique indexes
|
||||
- provider-specific options such as TTL, collation, shard keys, or JSON/BSON schema validation
|
||||
|
||||
MongoDB, for example, would implement a planner/materializer that maps `PersistenceStorageUnit` to collection and index creation. Data-shape changes would be handled by versioned migration operations, not by relational DDL.
|
||||
|
||||
## General-Purpose Reuse
|
||||
|
||||
This could be extracted into a reusable framework, but the reusable boundary should be below Elsa-specific module wiring:
|
||||
|
||||
- a core package for descriptors, version metadata, planning interfaces, and migration result contracts
|
||||
- provider-family packages for relational and document planning primitives
|
||||
- provider packages for SQLite, SQL Server, MongoDB, PostgreSQL, etc.
|
||||
- optional host integration packages for Elsa, ASP.NET Core, or worker services
|
||||
|
||||
The reusable core should not depend on Elsa features, EF Core, CShells, or any specific database driver. Elsa would then be a consumer that registers schema providers from modules and selects provider implementations.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
This POC intentionally does not solve schema diffing, data migrations, provider-specific advanced indexes, foreign keys, optimistic concurrency, or integration with Elsa shell features. The descriptor now includes a schema version and SQLite consumes it, but the runner applies the current full descriptor rather than a chain of per-version operations.
|
||||
|
||||
## Viability
|
||||
|
||||
The approach looks viable for simple module-owned storage units and indexes. Rendering both SQLite and SQL Server plus planning a document collection from the same Secrets descriptor suggests the module/provider boundary is useful. The SQLite version runner shows that provider-owned materialization can be idempotent without EF Core migrations. The main challenge remains evolution: Elsa would need a versioned descriptor/migration story so providers can apply each version safely, including non-idempotent changes and data backfills, before attempting automatic diffs.
|
||||
535
design/persistence-vnext-roadmap.md
Normal file
535
design/persistence-vnext-roadmap.md
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
# Persistence vNext Roadmap
|
||||
|
||||
## Vision
|
||||
|
||||
Persistence vNext should let modular applications declare persistence intent once and let database providers materialize that intent using the best physical model available to them.
|
||||
|
||||
For Elsa, this means modules can contribute storage manifests, indexes, and runtime entity definitions without owning SQL Server, SQLite, PostgreSQL, Oracle, MySQL, MongoDB, or EF Core migration projects.
|
||||
|
||||
The long-term model is:
|
||||
|
||||
- portable by default
|
||||
- provider-optimized when needed
|
||||
- document-first for dynamic/runtime entities
|
||||
- explicit-index driven for portable queries
|
||||
- extensible enough for relational and document databases
|
||||
- independent enough to become reusable outside Elsa
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Persistence vNext should not become:
|
||||
|
||||
- a universal ORM
|
||||
- a LINQ provider
|
||||
- an EF Core replacement for all data access
|
||||
- a lowest-common-denominator database API
|
||||
- a reporting/BI engine
|
||||
- a magic cross-database query translator
|
||||
|
||||
Its job is to own modular persistence manifests, schema/index materialization, versioning, and a focused document/index query model.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Modules declare intent, providers own physical shape.
|
||||
2. Schema authority is the manifest, not EF Core migrations.
|
||||
3. Runtime-defined entities use document storage, not table-per-entity by default.
|
||||
4. Portable queries run through declared indexes.
|
||||
5. Advanced provider behavior is exposed through capabilities and opt-in policies.
|
||||
6. Provider-specific escape hatches are acceptable when explicit.
|
||||
7. The core abstractions must not depend on Elsa, EF Core, MongoDB, or any one provider.
|
||||
8. Start with simple, reliable append-only versioning before automatic diffing.
|
||||
9. Optimize hot paths without sacrificing the default portable path.
|
||||
|
||||
## Target Package Shape
|
||||
|
||||
### General-Purpose Packages
|
||||
|
||||
- `ModularPersistence.Abstractions`
|
||||
- storage manifests
|
||||
- storage units
|
||||
- fields
|
||||
- indexes
|
||||
- versions
|
||||
- provider capabilities
|
||||
- planner, renderer, migrator contracts
|
||||
|
||||
- `ModularPersistence.Document`
|
||||
- document descriptors
|
||||
- collection/index plans
|
||||
- portable document session abstractions
|
||||
- index-first query model
|
||||
|
||||
- `ModularPersistence.Relational`
|
||||
- relational plans
|
||||
- SQL operation model
|
||||
- relational schema history conventions
|
||||
- SQL execution abstractions
|
||||
|
||||
- `ModularPersistence.Sqlite`
|
||||
- SQLite type mapping
|
||||
- SQL rendering
|
||||
- schema history
|
||||
- portable document store implementation
|
||||
|
||||
- `ModularPersistence.SqlServer`
|
||||
- SQL Server type mapping
|
||||
- SQL rendering
|
||||
- schema history
|
||||
- optimized index strategies
|
||||
|
||||
- `ModularPersistence.PostgreSql`
|
||||
- PostgreSQL type mapping
|
||||
- JSONB options
|
||||
- expression/generated indexes where useful
|
||||
|
||||
- `ModularPersistence.MongoDb`
|
||||
- collection/index planning
|
||||
- native MongoDB indexes
|
||||
- document session implementation
|
||||
- optional schema validation
|
||||
|
||||
### Elsa Packages
|
||||
|
||||
- `Elsa.Persistence.VNext`
|
||||
- Elsa module integration
|
||||
- shell feature wiring
|
||||
- options
|
||||
- module discovery
|
||||
- startup migration orchestration
|
||||
|
||||
- `Elsa.Persistence.VNext.RuntimeEntities`
|
||||
- runtime entity definitions
|
||||
- admin-defined schema lifecycle
|
||||
- generated API/UI integration points
|
||||
- permissions and audit hooks
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Storage Manifest
|
||||
|
||||
A module or runtime admin defines:
|
||||
|
||||
- schema name
|
||||
- schema version
|
||||
- storage units
|
||||
- fields
|
||||
- keys
|
||||
- indexes
|
||||
- validation metadata
|
||||
- provider requirements
|
||||
- optional physicalization policy
|
||||
|
||||
### Storage Unit
|
||||
|
||||
A storage unit is provider-neutral. Providers map it differently:
|
||||
|
||||
- relational: table or document table/index table
|
||||
- MongoDB: collection or shared collection
|
||||
- Cosmos DB: container/partition strategy
|
||||
- in-memory: dictionary plus indexes
|
||||
|
||||
### Document Store
|
||||
|
||||
Runtime entities and many module aggregates should be stored as documents:
|
||||
|
||||
- `Id`
|
||||
- `Type`
|
||||
- `TenantId`
|
||||
- `Version`
|
||||
- `CreatedAt`
|
||||
- `UpdatedAt`
|
||||
- `Data`
|
||||
- optional metadata
|
||||
|
||||
### Indexes
|
||||
|
||||
Indexes are the portable query surface.
|
||||
|
||||
Portable queries should only target declared indexes. Providers decide how to implement them:
|
||||
|
||||
- relational generic field index table
|
||||
- relational typed index table
|
||||
- SQL generated/computed column
|
||||
- MongoDB native index
|
||||
- in-memory lookup table
|
||||
|
||||
### Physicalization Policy
|
||||
|
||||
Each storage unit or index can choose a policy:
|
||||
|
||||
- `PortableDocument`
|
||||
- safest default
|
||||
- shared document table/collection plus generic indexes
|
||||
|
||||
- `OptimizedIndexes`
|
||||
- provider creates stronger typed/materialized indexes
|
||||
- still document-backed
|
||||
|
||||
- `NativePhysicalized`
|
||||
- provider may create typed tables, collection-per-entity, generated columns, JSONB expression indexes, etc.
|
||||
- opt-in for hot paths
|
||||
|
||||
## Roadmap
|
||||
|
||||
## Phase 0: Research And Validation
|
||||
|
||||
Goal: validate the design against Elsa's real persistence surface.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Inventory current EF Core modules, stores, migrations, and provider packages.
|
||||
- Classify stores into document-friendly, relational-specific, and high-performance hot paths.
|
||||
- Identify query patterns per module.
|
||||
- Compare YesSQL, Marten, MongoDB, EF Core, FluentMigrator, Liquibase, and Orchard Core data migrations.
|
||||
- Decide which Elsa module is the first production candidate.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Persistence inventory completed.
|
||||
- First production module candidate selected.
|
||||
- Provider priorities selected.
|
||||
|
||||
Recommended first candidates:
|
||||
|
||||
- Secrets
|
||||
- Labels
|
||||
- Tenants
|
||||
- simple diagnostics stores
|
||||
|
||||
Avoid first:
|
||||
|
||||
- workflow runtime hot paths
|
||||
- workflow instance queries
|
||||
- execution log history
|
||||
|
||||
## Phase 1: Extract Clean Core Abstractions
|
||||
|
||||
Goal: turn the POC into clean reusable contracts.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Rename POC concepts into neutral package names.
|
||||
- Separate core abstractions from Elsa.
|
||||
- Finalize descriptor vocabulary:
|
||||
- manifest
|
||||
- storage unit
|
||||
- field
|
||||
- key
|
||||
- index
|
||||
- version
|
||||
- capability
|
||||
- Add provider capability checks.
|
||||
- Add validation errors and diagnostics.
|
||||
- Add contract tests for descriptors and planners.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- No Elsa dependencies in core packages.
|
||||
- Descriptors support relational and document planning.
|
||||
- Provider capability validation exists.
|
||||
- SQLite and SQL Server render tests still pass.
|
||||
|
||||
## Phase 2: Portable Document Store MVP
|
||||
|
||||
Goal: implement a YesSQL-like portable document store over SQLite first.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Define `IDocumentStore`.
|
||||
- Define document envelope:
|
||||
- `Id`
|
||||
- `StorageUnit`
|
||||
- `Version`
|
||||
- `Content`
|
||||
- timestamps
|
||||
- Define explicit save and query request records.
|
||||
- Implement SQLite document table.
|
||||
- Implement generic field index table.
|
||||
- Implement save/load/delete.
|
||||
- Implement index maintenance in the same transaction.
|
||||
- Implement basic optimistic concurrency.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Can store and load documents.
|
||||
- Can maintain declared indexes.
|
||||
- Can query by declared indexes.
|
||||
- Startup materialization is idempotent.
|
||||
|
||||
## Phase 3: Portable Query Model
|
||||
|
||||
Goal: define useful queries without becoming an ORM.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Define index-first query API.
|
||||
- Support:
|
||||
- equals
|
||||
- not equals
|
||||
- in
|
||||
- range
|
||||
- starts-with where supported
|
||||
- null checks
|
||||
- sorting
|
||||
- paging
|
||||
- Add provider capability validation for unsupported operations.
|
||||
- Add query-plan diagnostics.
|
||||
- Add tests ensuring unindexed portable queries fail clearly.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Modules can query common indexes portably.
|
||||
- Unsupported query shapes produce clear errors.
|
||||
- Query API does not expose provider-specific behavior by accident.
|
||||
|
||||
## Phase 4: SQL Server And PostgreSQL Providers
|
||||
|
||||
Goal: prove the relational provider model across serious relational engines.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Implement SQL Server document store.
|
||||
- Implement PostgreSQL document store.
|
||||
- Support generic indexes.
|
||||
- Add provider-specific optimized index options:
|
||||
- SQL Server computed columns where useful
|
||||
- PostgreSQL JSONB/expression indexes where useful
|
||||
- Add transaction and locking strategy.
|
||||
- Add provider integration tests.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Same document/index tests pass on SQLite, SQL Server, and PostgreSQL.
|
||||
- Provider differences are isolated to provider packages.
|
||||
- Startup race conditions are handled.
|
||||
|
||||
## Phase 5: MongoDB Provider
|
||||
|
||||
Goal: prove native document database support.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Implement MongoDB document store.
|
||||
- Decide shared collection versus collection-per-type defaults.
|
||||
- Map storage units to collections.
|
||||
- Map declared indexes to MongoDB indexes.
|
||||
- Add optimistic concurrency.
|
||||
- Add basic transaction support when available.
|
||||
- Add provider capability checks for unsupported relational assumptions.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Same document/index tests pass on MongoDB.
|
||||
- MongoDB uses native indexes.
|
||||
- No relational tables/index assumptions leak into the document provider.
|
||||
|
||||
## Phase 6: Elsa Integration
|
||||
|
||||
Goal: consume the general packages from Elsa.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Add Elsa-specific registration package.
|
||||
- Let modules register manifests through module features.
|
||||
- Add startup materialization service.
|
||||
- Add shell feature support.
|
||||
- Add options for selected provider and connection settings.
|
||||
- Add diagnostics endpoint for applied manifests and provider status.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- An Elsa module can declare storage intent without EF Core.
|
||||
- Elsa startup materializes the selected provider.
|
||||
- Applied versions are observable.
|
||||
|
||||
## Phase 7: First Elsa Module Migration
|
||||
|
||||
Goal: migrate one real module safely.
|
||||
|
||||
Recommended module: Secrets.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Implement Secrets document store.
|
||||
- Preserve existing API behavior.
|
||||
- Add import path from EF Core Secrets tables if needed.
|
||||
- Add dual-read or explicit migration utility if required.
|
||||
- Add provider tests for SQLite, SQL Server, and MongoDB.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Secrets works without EF Core migrations.
|
||||
- Existing tests pass.
|
||||
- Provider-specific persistence packages are no longer needed for Secrets vNext.
|
||||
|
||||
## Phase 8: Runtime-Defined Entities
|
||||
|
||||
Goal: let admins define entities at runtime.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Define `RuntimeEntityDefinition`.
|
||||
- Add draft/published/retired lifecycle.
|
||||
- Add validation rules.
|
||||
- Add index definitions.
|
||||
- Add provider capability checks before publish.
|
||||
- Materialize published definitions.
|
||||
- Generate CRUD endpoints.
|
||||
- Expose admin UI integration points.
|
||||
- Add audit trail for schema changes.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Admin can define an entity.
|
||||
- System validates and publishes it.
|
||||
- Data can be created, queried by indexed fields, updated, and deleted.
|
||||
- No physical table per entity is required by default.
|
||||
|
||||
## Phase 9: Physicalization And Performance
|
||||
|
||||
Goal: optimize hot entities and indexes without giving up portability.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Add storage policy:
|
||||
- `PortableDocument`
|
||||
- `OptimizedIndexes`
|
||||
- `NativePhysicalized`
|
||||
- Add provider-specific physicalization planners.
|
||||
- Add operational commands to promote/demote indexes.
|
||||
- Add benchmarks.
|
||||
- Add guidance for when to physicalize.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Portable defaults remain simple.
|
||||
- Hot-path indexes can be optimized.
|
||||
- Provider-specific behavior is explicit and observable.
|
||||
|
||||
Local progress note: the POC now includes provider-neutral physicalization policies with schema validation plus SQLite and MongoDB planners. SQLite emits dedicated table/index DDL operations; MongoDB emits dedicated collection/native-index operations. This proves the extension point, but representative benchmarks and operational promote/demote commands are still needed.
|
||||
|
||||
## Phase 10: Workflow Runtime Evaluation
|
||||
|
||||
Goal: determine whether high-throughput workflow runtime persistence should use vNext.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Benchmark workflow instance queries.
|
||||
- Benchmark bookmark and trigger queries.
|
||||
- Evaluate lock/concurrency semantics.
|
||||
- Evaluate event/log volume storage.
|
||||
- Decide where specialized persistence remains better.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Clear decision for runtime stores.
|
||||
- No premature migration of hot workflow internals.
|
||||
|
||||
Likely outcome:
|
||||
|
||||
- Management/catalog/metadata stores are good candidates.
|
||||
- Runtime execution hot paths may need specialized providers or optimized physicalization.
|
||||
|
||||
Local progress note: the POC includes a documented go/no-go decision and evaluator. The current recommendation is to use vNext for management/module metadata first, gate hot lookups behind benchmarks and physicalization, and keep queues/logs on specialized providers until runtime contract tests prove otherwise.
|
||||
|
||||
## Phase 11: Hardening
|
||||
|
||||
Goal: make the framework production-safe.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Startup locking.
|
||||
- Transaction strategy.
|
||||
- Retry strategy.
|
||||
- Idempotency guarantees.
|
||||
- Version history repair tools.
|
||||
- Provider diagnostics.
|
||||
- Backup/restore guidance.
|
||||
- Multi-tenant isolation model.
|
||||
- Security and permission model for runtime schemas.
|
||||
- Roll-forward migration guidance.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Safe concurrent startup.
|
||||
- Clear recovery story.
|
||||
- Provider failures are diagnosable.
|
||||
|
||||
## Phase 12: General-Purpose OSS Extraction
|
||||
|
||||
Goal: publish the reusable framework outside Elsa.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Finalize package names.
|
||||
- Remove Elsa-specific concepts.
|
||||
- Add examples:
|
||||
- ASP.NET Core modular monolith
|
||||
- SQLite
|
||||
- SQL Server
|
||||
- MongoDB
|
||||
- Add documentation.
|
||||
- Add provider compatibility matrix.
|
||||
- Define semantic versioning policy.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Elsa consumes the general packages.
|
||||
- Non-Elsa sample app works.
|
||||
- Public API is stable enough for external users.
|
||||
|
||||
## Migration Strategy For Elsa
|
||||
|
||||
1. Keep current EF Core persistence intact.
|
||||
2. Introduce vNext side-by-side.
|
||||
3. Use vNext for new experimental modules first.
|
||||
4. Migrate small modules next.
|
||||
5. Provide import utilities for existing data.
|
||||
6. Avoid dual-write unless absolutely necessary.
|
||||
7. Do not migrate runtime hot paths until benchmarks justify it.
|
||||
|
||||
## Major Risks
|
||||
|
||||
- Query abstraction grows too large.
|
||||
- Provider capabilities become hard to reason about.
|
||||
- Document indexes become expensive on relational providers.
|
||||
- Runtime-defined entities introduce product/security complexity.
|
||||
- Automatic diffs become unreliable.
|
||||
- Physicalization policies create operational burden.
|
||||
- MongoDB and relational transaction semantics diverge.
|
||||
|
||||
## Key Decisions To Make Early
|
||||
|
||||
- Package naming and ownership.
|
||||
- Whether this starts as Elsa-internal or separate repo.
|
||||
- First production Elsa module.
|
||||
- Provider priority order.
|
||||
- Whether PostgreSQL or SQL Server follows SQLite.
|
||||
- MongoDB shared collection versus collection-per-type default.
|
||||
- How much query expressiveness is allowed in portable mode.
|
||||
- Runtime entity UI scope.
|
||||
|
||||
## Suggested Execution Order
|
||||
|
||||
1. Finish core abstractions in the POC.
|
||||
2. Build SQLite document store.
|
||||
3. Convert Secrets to use the document store.
|
||||
4. Add SQL Server provider.
|
||||
5. Add MongoDB provider.
|
||||
6. Add Elsa integration.
|
||||
7. Add runtime-defined entities.
|
||||
8. Add physicalization policies.
|
||||
9. Decide whether to extract as OSS.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
This initiative succeeds if:
|
||||
|
||||
- modules declare persistence once
|
||||
- provider-specific migrations are no longer required per module
|
||||
- relational and MongoDB providers both work
|
||||
- runtime-defined entities can be stored without table-per-entity
|
||||
- portable queries are predictable and index-driven
|
||||
- hot paths have opt-in physicalization
|
||||
- Elsa remains database agnostic
|
||||
- the reusable core stays small enough to understand
|
||||
85
design/persistence-vnext-workflow-runtime-evaluation.md
Normal file
85
design/persistence-vnext-workflow-runtime-evaluation.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Persistence vNext Workflow Runtime Evaluation
|
||||
|
||||
## Decision
|
||||
|
||||
Persistence vNext is viable for Elsa management, catalog, metadata, admin-defined entity, and small module stores. It should not replace workflow runtime hot-path stores yet.
|
||||
|
||||
The current go/no-go decision is:
|
||||
|
||||
- Go: workflow definitions, metadata stores, secrets, labels, tenants, admin-defined entities, and other document-friendly module data.
|
||||
- Conditional go: workflow instance management/query surfaces if provider benchmarks prove the declared indexes are fast enough, preferably with physicalization policies for common filters.
|
||||
- No-go for now: bookmarks, triggers, bookmark queues, dispatch/outbox queues, activity execution records, and workflow execution logs.
|
||||
|
||||
## Why Runtime Stores Are Different
|
||||
|
||||
Workflow runtime stores are not only persistence concerns. They encode runtime semantics:
|
||||
|
||||
- bookmark and trigger lookup latency
|
||||
- distributed locking
|
||||
- queue visibility, retry, and dead-letter behavior
|
||||
- append-heavy log/event volume
|
||||
- cleanup and retention behavior
|
||||
- concurrency under multiple workers
|
||||
|
||||
A portable document/index layer can represent these records, but representation alone is not enough. The provider must also prove that runtime semantics are correct and fast under load.
|
||||
|
||||
## Recommended Runtime Strategy
|
||||
|
||||
Keep specialized runtime providers until three conditions are met:
|
||||
|
||||
1. Representative benchmarks exist for SQLite, SQL Server, PostgreSQL, and MongoDB.
|
||||
2. Provider contract tests validate concurrency, locking, retry, and cleanup behavior.
|
||||
3. Hot-path storage units have explicit physicalization policies with rollback guidance.
|
||||
|
||||
Use Persistence vNext first for stores where the risk profile is lower:
|
||||
|
||||
- management/catalog data
|
||||
- module configuration/state
|
||||
- runtime-defined entities
|
||||
- operational metadata
|
||||
- queryable admin data
|
||||
|
||||
## Physicalization Requirement
|
||||
|
||||
If workflow instances or other high-volume records move to vNext, they should use portable documents by default in development, but production providers should be able to opt into:
|
||||
|
||||
- relational dedicated tables
|
||||
- relational generated or typed index columns
|
||||
- MongoDB dedicated collections
|
||||
- native provider indexes
|
||||
- retention/cleanup commands
|
||||
|
||||
This keeps module intent provider-neutral while letting providers own the physical performance shape.
|
||||
|
||||
## Required Evidence Before Migrating Hot Paths
|
||||
|
||||
Benchmarks should cover:
|
||||
|
||||
- workflow instance queries by status, sub-status, definition id, version, correlation id, and updated timestamp
|
||||
- bookmark lookup by activity type, hash, workflow instance id, and tenant id
|
||||
- trigger lookup by hash, workflow definition id, and tenant id
|
||||
- queue dequeue/lease/retry/dead-letter behavior
|
||||
- log append and retention cleanup throughput
|
||||
|
||||
Contract tests should cover:
|
||||
|
||||
- optimistic concurrency failures
|
||||
- distributed lock behavior
|
||||
- duplicate startup materialization
|
||||
- worker retry behavior
|
||||
- transactional index updates
|
||||
- recovery after partial materialization failure
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. Keep current EF Core runtime persistence intact.
|
||||
2. Add vNext-backed stores only for low-risk module and management data.
|
||||
3. Add benchmark fixtures for workflow runtime query shapes.
|
||||
4. Add provider contract tests for locking, queues, and append-heavy logs.
|
||||
5. Introduce physicalization policies for workflow instance and bookmark indexes.
|
||||
6. Pilot one non-critical runtime-adjacent store.
|
||||
7. Only migrate runtime hot paths after benchmarks and contract tests pass across the supported providers.
|
||||
|
||||
## POC Status
|
||||
|
||||
The POC now includes a small evaluator that codifies this decision. It classifies metadata stores as vNext candidates, hot distributed lookups as benchmark-gated, and queue/log workloads as specialized-provider territory for now.
|
||||
34
specs/011-persistence-vnext/checklists/requirements.md
Normal file
34
specs/011-persistence-vnext/checklists/requirements.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Specification Quality Checklist: Persistence vNext
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-06-02
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Specification is ready for implementation planning.
|
||||
228
specs/011-persistence-vnext/roadmap.md
Normal file
228
specs/011-persistence-vnext/roadmap.md
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
# Persistence vNext Executable Roadmap
|
||||
|
||||
Parent issue: [#7580](https://github.com/elsa-workflows/elsa-core/issues/7580)
|
||||
|
||||
## S1 - Core Manifest And Planner Foundations
|
||||
|
||||
Outcome: Modules can declare provider-neutral storage intent once, and relational/document planners can consume that same manifest.
|
||||
|
||||
Scope:
|
||||
|
||||
- Core descriptors.
|
||||
- Provider extension contracts.
|
||||
- Relational planner.
|
||||
- Document planner.
|
||||
- Secrets sample manifest.
|
||||
- SQLite and SQL Server render proof.
|
||||
|
||||
Non-scope:
|
||||
|
||||
- Full document store.
|
||||
- Runtime entity UI.
|
||||
- Production provider packages.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Same Secrets manifest produces relational and document plans.
|
||||
- SQLite DDL can execute.
|
||||
- SQL Server DDL can render.
|
||||
- Schema history records applied version.
|
||||
|
||||
Validation:
|
||||
|
||||
- `dotnet test test/unit/Elsa.Persistence.VNext.UnitTests/Elsa.Persistence.VNext.UnitTests.csproj`
|
||||
|
||||
GitHub issue mapping: [#7670](https://github.com/elsa-workflows/elsa-core/issues/7670)
|
||||
|
||||
## S2 - Portable SQLite Document Store MVP
|
||||
|
||||
Outcome: A provider-neutral document store can save, load, delete, index, and query documents on SQLite.
|
||||
|
||||
Scope:
|
||||
|
||||
- Document envelope.
|
||||
- SQLite document table.
|
||||
- Generic field index table.
|
||||
- Save/load/delete.
|
||||
- Index maintenance.
|
||||
- Basic query by declared indexes.
|
||||
- Optimistic concurrency.
|
||||
|
||||
Non-scope:
|
||||
|
||||
- SQL Server/MongoDB providers.
|
||||
- Runtime entity admin UI.
|
||||
- Physicalization policy.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Documents can be saved and loaded.
|
||||
- Indexed fields are maintained transactionally.
|
||||
- Queries by declared indexes return expected documents.
|
||||
- Unindexed queries fail clearly.
|
||||
|
||||
Validation:
|
||||
|
||||
- Targeted unit and SQLite integration tests.
|
||||
|
||||
GitHub issue mapping: [#7671](https://github.com/elsa-workflows/elsa-core/issues/7671)
|
||||
|
||||
## S3 - SQL Server And PostgreSQL Relational Document Providers
|
||||
|
||||
Outcome: The portable document/index contract runs on SQL Server and PostgreSQL.
|
||||
|
||||
Scope:
|
||||
|
||||
- SQL Server document store.
|
||||
- PostgreSQL document store.
|
||||
- Generic field indexes.
|
||||
- Startup locking/materialization.
|
||||
- Provider integration tests.
|
||||
|
||||
Non-scope:
|
||||
|
||||
- MongoDB.
|
||||
- Runtime entity UI.
|
||||
- Advanced physicalization.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Same document/index contract tests pass on SQLite, SQL Server, and PostgreSQL.
|
||||
- Provider differences remain isolated to provider packages.
|
||||
|
||||
GitHub issue mapping: [#7672](https://github.com/elsa-workflows/elsa-core/issues/7672)
|
||||
|
||||
Local progress note: provider packages and SQL dialect tests are implemented locally. Live SQL Server/PostgreSQL contract tests require a running Docker/Testcontainers environment or CI worker.
|
||||
|
||||
## S4 - MongoDB Document Provider
|
||||
|
||||
Outcome: The same storage/index model works on MongoDB using native collections and indexes.
|
||||
|
||||
Scope:
|
||||
|
||||
- MongoDB document store.
|
||||
- Native collection/index materialization.
|
||||
- Query translation for declared indexes.
|
||||
- Optimistic concurrency.
|
||||
- Provider capability validation.
|
||||
|
||||
Non-scope:
|
||||
|
||||
- Aggregation pipelines beyond portable query contract.
|
||||
- Runtime entity UI.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Same document/index contract tests pass on MongoDB.
|
||||
- Native indexes are created and used for declared indexes.
|
||||
|
||||
GitHub issue mapping: [#7673](https://github.com/elsa-workflows/elsa-core/issues/7673)
|
||||
|
||||
Local progress note: MongoDB provider package, native collection/index planner, query translation, and expected-version write/delete filters are implemented locally. Live MongoDB contract tests require a running MongoDB service or CI worker.
|
||||
|
||||
## S5 - Elsa Integration And First Module Migration
|
||||
|
||||
Outcome: Elsa consumes vNext persistence and one real module no longer needs EF Core migrations.
|
||||
|
||||
Scope:
|
||||
|
||||
- Elsa registration package.
|
||||
- Shell feature/options.
|
||||
- Startup materialization.
|
||||
- Diagnostics.
|
||||
- Secrets module vNext store.
|
||||
- EF Core import path if required.
|
||||
|
||||
Non-scope:
|
||||
|
||||
- Workflow runtime hot paths.
|
||||
- Runtime-defined entities.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Secrets works on vNext persistence.
|
||||
- Provider-specific EF Core migration packages are not required for Secrets vNext.
|
||||
- Existing behavior is preserved.
|
||||
|
||||
GitHub issue mapping: [#7674](https://github.com/elsa-workflows/elsa-core/issues/7674)
|
||||
|
||||
Local progress note: opt-in Elsa integration package, startup materialization task, status snapshot, and Secrets vNext repository are implemented locally. Existing Secrets persistence remains intact; import/migration from existing stores is intentionally left open until the storage model is proven further.
|
||||
|
||||
## S6 - Runtime-Defined Entities
|
||||
|
||||
Outcome: Admin-defined entities can be published, persisted, and queried through the document/index model.
|
||||
|
||||
Scope:
|
||||
|
||||
- Runtime entity definitions.
|
||||
- Draft/published/retired lifecycle.
|
||||
- Validation.
|
||||
- Index definitions.
|
||||
- CRUD/API integration.
|
||||
- Audit trail.
|
||||
|
||||
Non-scope:
|
||||
|
||||
- Full Studio UI polish.
|
||||
- Native physicalization.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Admin-defined entity can be published.
|
||||
- Instances can be created and queried by indexed fields.
|
||||
- No physical table per runtime entity is required by default.
|
||||
|
||||
GitHub issue mapping: [#7675](https://github.com/elsa-workflows/elsa-core/issues/7675)
|
||||
|
||||
Local progress note: runtime entity definitions, draft/published/retired lifecycle, validation, fixed-slot indexed instance storage, service-level CRUD/query API, and audit records are implemented locally. The POC intentionally does not add Studio UI or HTTP endpoints yet.
|
||||
|
||||
## S7 - Physicalization And Performance
|
||||
|
||||
Outcome: Hot entities and indexes can opt into provider-optimized physical storage.
|
||||
|
||||
Scope:
|
||||
|
||||
- Storage policies.
|
||||
- Optimized indexes.
|
||||
- Native physicalization hooks.
|
||||
- Benchmarks.
|
||||
- Provider capability validation.
|
||||
|
||||
Non-scope:
|
||||
|
||||
- Universal auto-optimization.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- At least one relational provider supports optimized indexes.
|
||||
- At least one document provider supports provider-native optimization.
|
||||
- Portable default remains unchanged.
|
||||
|
||||
GitHub issue mapping: [#7676](https://github.com/elsa-workflows/elsa-core/issues/7676)
|
||||
|
||||
Local progress note: provider-neutral physicalization policies and manifest validation are implemented locally. SQLite can plan dedicated table/index DDL for hot storage units, and MongoDB can plan dedicated collection/native-index operations. Real workload benchmarks remain open.
|
||||
|
||||
## S8 - Workflow Runtime Evaluation And Hardening
|
||||
|
||||
Outcome: vNext is evaluated against workflow runtime hot paths and hardened for production.
|
||||
|
||||
Scope:
|
||||
|
||||
- Runtime store benchmark.
|
||||
- Concurrency/locking validation.
|
||||
- Retry and diagnostics.
|
||||
- Backup/recovery guidance.
|
||||
- Provider status reporting.
|
||||
|
||||
Non-scope:
|
||||
|
||||
- Premature runtime migration without benchmark evidence.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Clear go/no-go decision for workflow runtime stores.
|
||||
- Production operational risks are documented and mitigated.
|
||||
|
||||
GitHub issue mapping: [#7677](https://github.com/elsa-workflows/elsa-core/issues/7677)
|
||||
|
||||
Local progress note: a workflow runtime evaluator and go/no-go design note are implemented locally. Metadata/module stores are classified as good vNext candidates, hot lookup stores are benchmark-gated, and queue/log workloads remain specialized-provider candidates. Real provider benchmarks and concurrency/locking contract tests remain open.
|
||||
169
specs/011-persistence-vnext/spec.md
Normal file
169
specs/011-persistence-vnext/spec.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Feature Specification: Persistence vNext
|
||||
|
||||
**Feature Branch**: `codex/persistence-vnext-poc`
|
||||
**Created**: 2026-06-02
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Execute the Persistence vNext roadmap end to end: provider-neutral modular persistence for Elsa, with relational and document database support, runtime-defined entities, portable document/index storage by default, provider-optimized physicalization by policy, PR review/merge flow, and issue completion tracking."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Module-Owned Storage Intent (Priority: P1)
|
||||
|
||||
As an Elsa module author, I want to declare the storage shape and query indexes my module needs once, without referencing SQL Server, SQLite, EF Core, MongoDB, or any provider-specific migration package.
|
||||
|
||||
**Why this priority**: This removes the current per-module/per-provider migration maintenance burden and establishes the foundation for every later slice.
|
||||
|
||||
**Independent Test**: A module can register a versioned storage manifest, and separate relational and document planners can produce provider-family plans from the same manifest.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a module manifest with a storage unit, fields, key, and indexes, **When** a relational planner processes it, **Then** it produces a table/index plan without requiring provider-specific module code.
|
||||
2. **Given** the same module manifest, **When** a document planner processes it, **Then** it produces a collection/index plan without requiring relational descriptors.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Provider-Owned Materialization (Priority: P1)
|
||||
|
||||
As an Elsa operator, I want the selected persistence provider to materialize schema/indexes from module manifests so modules do not ship provider-specific migrations.
|
||||
|
||||
**Why this priority**: This is the central replacement for EF Core migrations as schema authority.
|
||||
|
||||
**Independent Test**: SQLite can execute generated schema/index operations and record the applied schema version; SQL Server can render provider-specific DDL from the same manifest.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a manifest version that has not been applied, **When** SQLite startup materialization runs, **Then** required tables/indexes are created and the version is recorded.
|
||||
2. **Given** the same manifest version already recorded, **When** SQLite startup materialization runs again, **Then** no schema operations are re-applied.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Portable Document Store (Priority: P1)
|
||||
|
||||
As an Elsa module author, I want to persist and query module documents using declared indexes, so simple modules do not need custom EF Core, SQL, or MongoDB code.
|
||||
|
||||
**Why this priority**: A YesSQL-like document/index model is the practical way to support both relational and native document databases.
|
||||
|
||||
**Independent Test**: A document can be saved, loaded, deleted, and queried by declared indexed fields on SQLite.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a document type with indexed fields, **When** the document is saved, **Then** provider-owned index records are maintained.
|
||||
2. **Given** a query against a declared index, **When** the query executes, **Then** matching documents are returned with paging and sorting where supported.
|
||||
3. **Given** a query against an undeclared index, **When** the query is validated, **Then** it fails with a clear provider-neutral error.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Native Document Provider Support (Priority: P2)
|
||||
|
||||
As an Elsa deployer, I want to run the same module persistence model on MongoDB so Elsa remains database agnostic.
|
||||
|
||||
**Why this priority**: The abstraction is not successful unless document databases can use native collections/indexes rather than relational tables.
|
||||
|
||||
**Independent Test**: The same document/index contract passes against MongoDB with native index creation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a storage manifest, **When** MongoDB materialization runs, **Then** collections and native indexes are created.
|
||||
2. **Given** indexed document queries, **When** they execute against MongoDB, **Then** they use the provider's native query/index model.
|
||||
|
||||
---
|
||||
|
||||
### User Story 5 - Runtime-Defined Entities (Priority: P2)
|
||||
|
||||
As an Elsa administrator, I want to define entities at runtime and query them by declared indexed fields without creating a physical table per entity by default.
|
||||
|
||||
**Why this priority**: Runtime-defined entities unlock admin-defined domain concepts while preserving provider portability.
|
||||
|
||||
**Independent Test**: An admin-defined entity definition can be published, materialized, used for CRUD, and queried by declared indexes.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a draft entity definition, **When** it is validated and published, **Then** the selected provider materializes required indexes.
|
||||
2. **Given** an instance of that runtime entity, **When** it is saved, **Then** it is stored as a document and queryable by declared indexes.
|
||||
|
||||
---
|
||||
|
||||
### User Story 6 - Provider-Optimized Physicalization (Priority: P3)
|
||||
|
||||
As an Elsa operator, I want to opt hot entities or indexes into provider-optimized physical shapes without changing module code.
|
||||
|
||||
**Why this priority**: Portable defaults must not block high-performance production workloads.
|
||||
|
||||
**Independent Test**: A storage policy can be changed from portable document storage to optimized indexes or native physicalization, and the provider reports what physical changes will be applied.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a hot index, **When** an optimized index policy is selected, **Then** the provider materializes a stronger provider-specific index.
|
||||
2. **Given** unsupported physicalization, **When** validation runs, **Then** the provider reports the unsupported capability before applying changes.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Multiple app instances start concurrently and attempt materialization.
|
||||
- A schema version is already applied.
|
||||
- A provider does not support a requested index or field type.
|
||||
- A runtime entity definition is changed while existing documents exist.
|
||||
- A query references a field that is not indexed.
|
||||
- A physicalization policy is unsupported by the selected provider.
|
||||
- A document save succeeds but index maintenance fails.
|
||||
- MongoDB transaction support is unavailable in a deployment topology.
|
||||
- Existing EF Core-backed data must be imported into vNext storage.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST support versioned storage manifests contributed by modules.
|
||||
- **FR-002**: System MUST support provider-neutral storage units, fields, keys, and indexes.
|
||||
- **FR-003**: System MUST expose planner contracts so relational and document providers can consume the same manifest.
|
||||
- **FR-004**: System MUST expose provider capability validation before materialization.
|
||||
- **FR-005**: System MUST support provider-owned schema/index materialization.
|
||||
- **FR-006**: System MUST record applied schema versions per manifest.
|
||||
- **FR-007**: System MUST support idempotent startup materialization.
|
||||
- **FR-008**: System MUST support a portable document store for runtime-defined and module-defined documents.
|
||||
- **FR-009**: System MUST support portable queries only through declared indexes.
|
||||
- **FR-010**: System MUST return clear errors for unsupported or unindexed portable queries.
|
||||
- **FR-011**: System MUST support relational providers through document tables and index storage.
|
||||
- **FR-012**: System MUST support MongoDB through native collections and native indexes.
|
||||
- **FR-013**: System MUST support runtime-defined entity definitions with draft, published, and retired states.
|
||||
- **FR-014**: System MUST support provider-optimized physicalization policies as an opt-in.
|
||||
- **FR-015**: System MUST allow existing Elsa modules to migrate gradually from EF Core persistence.
|
||||
- **FR-016**: System MUST keep EF Core migrations out of the vNext schema authority path.
|
||||
- **FR-017**: System MUST expose diagnostics for manifest versions, provider status, and materialization failures.
|
||||
- **FR-018**: System MUST provide validation and tests for each supported provider.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Persistence Manifest**: Versioned module or runtime schema declaration.
|
||||
- **Storage Unit**: Provider-neutral persisted document/entity shape.
|
||||
- **Field**: Provider-neutral stored value declaration.
|
||||
- **Index**: Declared query path and uniqueness constraint.
|
||||
- **Provider Capability**: Provider-reported support for storage and query features.
|
||||
- **Document Envelope**: Stored document metadata and payload.
|
||||
- **Runtime Entity Definition**: Admin-defined entity manifest with lifecycle metadata.
|
||||
- **Schema History Entry**: Record of an applied manifest version.
|
||||
- **Physicalization Policy**: Storage optimization mode selected per storage unit or index.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A module can declare persistence once and generate relational and document plans from the same manifest.
|
||||
- **SC-002**: SQLite materialization applies a manifest once and skips re-application on subsequent runs.
|
||||
- **SC-003**: A portable document store can save, load, delete, and query documents by declared indexes on SQLite.
|
||||
- **SC-004**: SQL Server and PostgreSQL providers pass the same portable document/index contract tests as SQLite.
|
||||
- **SC-005**: MongoDB provider passes the same document/index contract tests using native collections and indexes.
|
||||
- **SC-006**: Runtime-defined entities can be published and used without creating one physical table per entity by default.
|
||||
- **SC-007**: Unsupported provider capabilities are detected before materialization.
|
||||
- **SC-008**: At least one existing Elsa module runs on vNext persistence without EF Core migrations.
|
||||
- **SC-009**: Provider-specific migration projects are not required for the migrated vNext module.
|
||||
- **SC-010**: Hot-path physicalization can be enabled for at least one provider without changing module manifest code.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Runtime-defined entities use document storage by default.
|
||||
- Portable queries are index-driven and intentionally limited.
|
||||
- EF Core can remain a runtime query implementation where useful, but EF Core migrations are not the schema authority.
|
||||
- Provider-specific escape hatches are allowed when declared explicitly.
|
||||
- Workflow runtime hot paths are evaluated later and are not the first migration target.
|
||||
- Secrets is the preferred first real Elsa module migration candidate.
|
||||
- The reusable core should be extractable outside Elsa after validation against multiple providers.
|
||||
79
specs/011-persistence-vnext/tasks.md
Normal file
79
specs/011-persistence-vnext/tasks.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Persistence vNext Tasks
|
||||
|
||||
## S1 - Core Manifest And Planner Foundations
|
||||
|
||||
- [x] T001 Add provider-neutral storage descriptors.
|
||||
- [x] T002 Add relational planner and SQLite renderer.
|
||||
- [x] T003 Add SQL Server renderer.
|
||||
- [x] T004 Add document planner.
|
||||
- [x] T005 Add SQLite schema history/version runner.
|
||||
- [x] T006 Add Secrets sample manifest.
|
||||
- [x] T007 Add focused tests for relational, document, and version runner behavior.
|
||||
- [ ] T008 Open/update PR for S1.
|
||||
- [x] T009 Run self-review loop for S1.
|
||||
- [ ] T010 Merge S1 PR when no big issues remain.
|
||||
- [ ] T011 Close/complete associated S1 issue after merge.
|
||||
|
||||
## S2 - Portable SQLite Document Store MVP
|
||||
|
||||
- [x] T012 Define document envelope and store contracts.
|
||||
- [x] T013 Add SQLite document table materialization.
|
||||
- [x] T014 Implement save/load/delete.
|
||||
- [x] T015 Implement generic field index storage.
|
||||
- [x] T016 Implement index maintenance in the same transaction.
|
||||
- [x] T017 Implement query by declared indexes.
|
||||
- [x] T018 Add optimistic concurrency.
|
||||
- [x] T019 Add unindexed-query validation.
|
||||
- [x] T020 Add SQLite document store tests.
|
||||
- [ ] T021 Open/review/merge S2 PR and close associated issue.
|
||||
|
||||
## S3 - SQL Server And PostgreSQL Relational Document Providers
|
||||
|
||||
- [x] T022 Implement SQL Server document provider.
|
||||
- [x] T023 Implement PostgreSQL document provider.
|
||||
- [x] T024 Add startup locking/materialization strategy.
|
||||
- [ ] T025 Add relational provider contract tests.
|
||||
- [ ] T026 Open/review/merge S3 PR and close associated issue.
|
||||
|
||||
## S4 - MongoDB Document Provider
|
||||
|
||||
- [x] T027 Implement MongoDB document provider.
|
||||
- [x] T028 Materialize native collections and indexes.
|
||||
- [x] T029 Implement indexed query translation.
|
||||
- [x] T030 Add optimistic concurrency.
|
||||
- [ ] T031 Add MongoDB provider contract tests.
|
||||
- [ ] T032 Open/review/merge S4 PR and close associated issue.
|
||||
|
||||
## S5 - Elsa Integration And First Module Migration
|
||||
|
||||
- [x] T033 Add Elsa vNext persistence integration package.
|
||||
- [x] T034 Add shell feature/options/startup materialization.
|
||||
- [x] T035 Add diagnostics for applied manifests and provider status.
|
||||
- [x] T036 Implement Secrets vNext store.
|
||||
- [ ] T037 Add migration/import path from existing EF Core store if required.
|
||||
- [ ] T038 Open/review/merge S5 PR and close associated issue.
|
||||
|
||||
## S6 - Runtime-Defined Entities
|
||||
|
||||
- [x] T039 Define runtime entity definition model.
|
||||
- [x] T040 Add draft/published/retired lifecycle.
|
||||
- [x] T041 Add validation and capability checks.
|
||||
- [x] T042 Add CRUD/API integration.
|
||||
- [x] T043 Add audit trail.
|
||||
- [ ] T044 Open/review/merge S6 PR and close associated issue.
|
||||
|
||||
## S7 - Physicalization And Performance
|
||||
|
||||
- [x] T045 Add storage policy model.
|
||||
- [x] T046 Implement optimized relational indexes for one provider.
|
||||
- [x] T047 Implement native document-provider optimization for one provider.
|
||||
- [ ] T048 Add benchmarks.
|
||||
- [ ] T049 Open/review/merge S7 PR and close associated issue.
|
||||
|
||||
## S8 - Workflow Runtime Evaluation And Hardening
|
||||
|
||||
- [ ] T050 Benchmark runtime persistence candidates.
|
||||
- [ ] T051 Validate concurrency, locking, and retry behavior.
|
||||
- [x] T052 Add provider diagnostics and recovery guidance.
|
||||
- [x] T053 Document go/no-go decision for workflow runtime stores.
|
||||
- [ ] T054 Open/review/merge S8 PR and close associated issue.
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
using Elsa.Persistence.VNext.Contracts;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Extensions.Contracts;
|
||||
|
||||
public interface IPersistenceSchemaCatalog
|
||||
{
|
||||
IReadOnlyList<IPersistenceSchemaProvider> Providers { get; }
|
||||
IReadOnlyList<PersistenceSchema> Schemas { get; }
|
||||
PersistenceSchema DescribeSchema();
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Persistence.VNext.Extensions.Contracts;
|
||||
|
||||
public interface IPersistenceVNextStatus
|
||||
{
|
||||
PersistenceVNextStatusSnapshot Snapshot { get; }
|
||||
void RecordSuccess(PersistenceVNextStatusSnapshot snapshot);
|
||||
void RecordFailure(PersistenceVNextStatusSnapshot snapshot);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>
|
||||
Experimental Elsa integration services for Persistence vNext.
|
||||
</Description>
|
||||
<PackageTags>elsa module persistence vnext experimental integration</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\common\Elsa.Features\Elsa.Features.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Common\Elsa.Common.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext\Elsa.Persistence.VNext.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
using Elsa.Features.Services;
|
||||
using Elsa.Persistence.VNext.Extensions.Features;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Extensions;
|
||||
|
||||
public static class ModuleExtensions
|
||||
{
|
||||
public static IModule UsePersistenceVNext(this IModule module, Action<PersistenceVNextFeature>? configure = null)
|
||||
{
|
||||
module.Configure(configure);
|
||||
return module;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using Elsa.Common;
|
||||
using Elsa.Extensions;
|
||||
using Elsa.Persistence.VNext.Extensions.Contracts;
|
||||
using Elsa.Persistence.VNext.Extensions.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddPersistenceVNext(this IServiceCollection services, Action<PersistenceVNextOptions>? configureOptions = null)
|
||||
{
|
||||
if (configureOptions is not null)
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.AddOptions<PersistenceVNextOptions>();
|
||||
services.TryAddSingleton<IPersistenceSchemaCatalog, DefaultPersistenceSchemaCatalog>();
|
||||
services.TryAddSingleton<IPersistenceVNextStatus, DefaultPersistenceVNextStatus>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Scoped<IStartupTask, PersistenceVNextStartupTask>());
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
using Elsa.Features.Abstractions;
|
||||
using Elsa.Features.Services;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Extensions.Features;
|
||||
|
||||
public class PersistenceVNextFeature(IModule module) : FeatureBase(module)
|
||||
{
|
||||
public Action<PersistenceVNextOptions>? ConfigureOptions { get; set; }
|
||||
|
||||
public override void Apply()
|
||||
{
|
||||
Services.AddPersistenceVNext(ConfigureOptions);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait/>
|
||||
</Weavers>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Persistence.VNext.Extensions;
|
||||
|
||||
public class PersistenceVNextOptions
|
||||
{
|
||||
public string SchemaName { get; set; } = "Elsa";
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public bool MaterializeOnStartup { get; set; } = true;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
namespace Elsa.Persistence.VNext.Extensions;
|
||||
|
||||
public record PersistenceVNextStatusSnapshot(
|
||||
bool MaterializationEnabled,
|
||||
bool Succeeded,
|
||||
DateTimeOffset? LastAttemptedAt,
|
||||
DateTimeOffset? LastSucceededAt,
|
||||
IReadOnlyList<string> SchemaNames,
|
||||
IReadOnlyList<string> StorageUnits,
|
||||
IReadOnlyList<string> DocumentStoreTypes,
|
||||
string? ErrorMessage = null,
|
||||
IReadOnlyList<string>? RecoveryHints = null)
|
||||
{
|
||||
public IReadOnlyList<string> RecoveryHints { get; init; } = RecoveryHints ?? [];
|
||||
|
||||
public static PersistenceVNextStatusSnapshot NotStarted { get; } = new(
|
||||
MaterializationEnabled: false,
|
||||
Succeeded: false,
|
||||
LastAttemptedAt: null,
|
||||
LastSucceededAt: null,
|
||||
SchemaNames: [],
|
||||
StorageUnits: [],
|
||||
DocumentStoreTypes: []);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
using Elsa.Persistence.VNext.Contracts;
|
||||
using Elsa.Persistence.VNext.Extensions.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Extensions.Services;
|
||||
|
||||
public class DefaultPersistenceSchemaCatalog(IEnumerable<IPersistenceSchemaProvider> providers, IOptions<PersistenceVNextOptions> options) : IPersistenceSchemaCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<IPersistenceSchemaProvider> _providers = providers.ToList();
|
||||
private IReadOnlyList<PersistenceSchema>? _schemas;
|
||||
|
||||
public IReadOnlyList<IPersistenceSchemaProvider> Providers => _providers;
|
||||
public IReadOnlyList<PersistenceSchema> Schemas => _schemas ??= _providers.Select(x => x.DescribeSchema()).ToList();
|
||||
|
||||
public PersistenceSchema DescribeSchema()
|
||||
{
|
||||
var schemas = Schemas;
|
||||
var tables = schemas.SelectMany(x => x.Tables).ToList();
|
||||
var storageUnits = schemas.SelectMany(x => x.StorageUnits).ToList();
|
||||
|
||||
EnsureUniqueTables(tables);
|
||||
EnsureUniqueStorageUnits(storageUnits);
|
||||
|
||||
return new PersistenceSchema(options.Value.SchemaName, options.Value.SchemaVersion, tables, storageUnits);
|
||||
}
|
||||
|
||||
private static void EnsureUniqueTables(IEnumerable<PersistenceTable> tables)
|
||||
{
|
||||
var duplicate = tables
|
||||
.GroupBy(x => $"{x.Schema ?? string.Empty}.{x.Name}", StringComparer.Ordinal)
|
||||
.FirstOrDefault(x => x.Count() > 1);
|
||||
|
||||
if (duplicate is not null)
|
||||
throw new InvalidOperationException($"Persistence vNext table '{duplicate.Key}' is declared more than once.");
|
||||
}
|
||||
|
||||
private static void EnsureUniqueStorageUnits(IEnumerable<PersistenceStorageUnit> storageUnits)
|
||||
{
|
||||
var duplicate = storageUnits
|
||||
.GroupBy(x => $"{x.Namespace ?? string.Empty}.{x.Name}", StringComparer.Ordinal)
|
||||
.FirstOrDefault(x => x.Count() > 1);
|
||||
|
||||
if (duplicate is not null)
|
||||
throw new InvalidOperationException($"Persistence vNext storage unit '{duplicate.Key}' is declared more than once.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
using Elsa.Persistence.VNext.Extensions.Contracts;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Extensions.Services;
|
||||
|
||||
public class DefaultPersistenceVNextStatus : IPersistenceVNextStatus
|
||||
{
|
||||
private PersistenceVNextStatusSnapshot _snapshot = PersistenceVNextStatusSnapshot.NotStarted;
|
||||
|
||||
public PersistenceVNextStatusSnapshot Snapshot => _snapshot;
|
||||
|
||||
public void RecordSuccess(PersistenceVNextStatusSnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public void RecordFailure(PersistenceVNextStatusSnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using Elsa.Common;
|
||||
using Elsa.Persistence.VNext.Document;
|
||||
using Elsa.Persistence.VNext.Extensions.Contracts;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Extensions.Services;
|
||||
|
||||
public class PersistenceVNextStartupTask(
|
||||
IEnumerable<IDocumentStore> documentStores,
|
||||
IPersistenceSchemaCatalog schemaCatalog,
|
||||
IPersistenceVNextStatus status,
|
||||
IOptions<PersistenceVNextOptions> options,
|
||||
ILogger<PersistenceVNextStartupTask> logger) : IStartupTask
|
||||
{
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var schema = schemaCatalog.DescribeSchema();
|
||||
var stores = documentStores.ToList();
|
||||
var attemptedAt = DateTimeOffset.UtcNow;
|
||||
var schemaNames = schemaCatalog.Schemas.Select(x => x.Name).Order(StringComparer.Ordinal).ToList();
|
||||
var storageUnits = schema.StorageUnits.Select(x => x.Name).Order(StringComparer.Ordinal).ToList();
|
||||
var storeTypes = stores.Select(x => x.GetType().FullName ?? x.GetType().Name).Order(StringComparer.Ordinal).ToList();
|
||||
|
||||
if (!options.Value.MaterializeOnStartup)
|
||||
{
|
||||
status.RecordSuccess(new(false, true, attemptedAt, null, schemaNames, storageUnits, storeTypes));
|
||||
logger.LogInformation("Persistence vNext startup materialization is disabled. {SchemaCount} manifests are registered.", schemaCatalog.Schemas.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var store in stores)
|
||||
await store.MaterializeAsync(cancellationToken);
|
||||
|
||||
status.RecordSuccess(new(true, true, attemptedAt, DateTimeOffset.UtcNow, schemaNames, storageUnits, storeTypes));
|
||||
logger.LogInformation("Persistence vNext materialized {StorageUnitCount} storage units using {StoreCount} document store providers.", storageUnits.Count, stores.Count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
var recoveryHints = new[]
|
||||
{
|
||||
"Verify that every registered provider can connect to its database.",
|
||||
"Check that the configured database user can create or alter the required schema objects.",
|
||||
"If multiple nodes start concurrently, verify the provider's materialization lock strategy."
|
||||
};
|
||||
status.RecordFailure(new(true, false, attemptedAt, null, schemaNames, storageUnits, storeTypes, e.Message, recoveryHints));
|
||||
logger.LogError(e, "Persistence vNext materialization failed.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>
|
||||
Experimental MongoDB provider for Elsa Persistence vNext.
|
||||
</Description>
|
||||
<PackageTags>elsa module persistence vnext mongodb experimental</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext\Elsa.Persistence.VNext.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait ContinueOnCapturedContext="false" />
|
||||
</Weavers>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
using Elsa.Persistence.VNext.Document;
|
||||
|
||||
namespace Elsa.Persistence.VNext.MongoDb;
|
||||
|
||||
public record MongoDbCollectionPlan(
|
||||
string CollectionName,
|
||||
DocumentCollection Collection,
|
||||
IReadOnlyList<MongoDbIndexPlan> Indexes);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace Elsa.Persistence.VNext.MongoDb;
|
||||
|
||||
public record MongoDbDatabasePlan(
|
||||
IReadOnlyList<MongoDbCollectionPlan> Collections);
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
using Elsa.Persistence.VNext.Contracts;
|
||||
using Elsa.Persistence.VNext.Document;
|
||||
|
||||
namespace Elsa.Persistence.VNext.MongoDb;
|
||||
|
||||
public class MongoDbDatabasePlanner : IPersistenceSchemaPlanner<MongoDbDatabasePlan>
|
||||
{
|
||||
private readonly DocumentDatabasePlanner _documentPlanner = new();
|
||||
|
||||
public MongoDbDatabasePlan Plan(PersistenceSchema schema)
|
||||
{
|
||||
var documentPlan = _documentPlanner.Plan(schema);
|
||||
var collections = documentPlan.Collections.Select(PlanCollection).ToList();
|
||||
return new MongoDbDatabasePlan(collections);
|
||||
}
|
||||
|
||||
private static MongoDbCollectionPlan PlanCollection(DocumentCollection collection)
|
||||
{
|
||||
var collectionName = NormalizeName(collection.Namespace, collection.Name);
|
||||
var indexes = collection.Indexes
|
||||
.Select(index => new MongoDbIndexPlan(index.Name, index.Fields.Select(field => $"IndexValues.{field}").ToList(), index.IsUnique))
|
||||
.ToList();
|
||||
|
||||
return new MongoDbCollectionPlan(collectionName, collection, indexes);
|
||||
}
|
||||
|
||||
private static string NormalizeName(params string?[] segments)
|
||||
{
|
||||
var value = string.Join("_", segments.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
var chars = value.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' ? c : '_').ToArray();
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
using Elsa.Persistence.VNext.Document;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Elsa.Persistence.VNext.MongoDb;
|
||||
|
||||
public class MongoDbDocumentStore : IDocumentStore
|
||||
{
|
||||
private readonly IMongoDatabase _database;
|
||||
private readonly MongoDbDatabasePlan _plan;
|
||||
|
||||
public MongoDbDocumentStore(IMongoDatabase database, PersistenceSchema schema) : this(database, new MongoDbDatabasePlanner().Plan(schema))
|
||||
{
|
||||
}
|
||||
|
||||
public MongoDbDocumentStore(IMongoDatabase database, MongoDbDatabasePlan plan)
|
||||
{
|
||||
_database = database;
|
||||
_plan = plan;
|
||||
}
|
||||
|
||||
public async Task MaterializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var collectionPlan in _plan.Collections)
|
||||
{
|
||||
await EnsureCollectionExistsAsync(collectionPlan.CollectionName, cancellationToken);
|
||||
var collection = GetMongoCollection(collectionPlan);
|
||||
var indexes = collectionPlan.Indexes
|
||||
.Select(index => new CreateIndexModel<BsonDocument>(
|
||||
Builders<BsonDocument>.IndexKeys.Combine(index.Fields.Select(field => Builders<BsonDocument>.IndexKeys.Ascending(field))),
|
||||
new CreateIndexOptions { Name = index.Name, Unique = index.IsUnique }))
|
||||
.ToList();
|
||||
|
||||
if (indexes.Count > 0)
|
||||
await collection.Indexes.CreateManyAsync(indexes, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<StoredDocument> SaveAsync(SaveDocumentRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var collectionPlan = GetCollectionPlan(request.StorageUnit);
|
||||
ValidateSaveRequest(collectionPlan.Collection, request);
|
||||
var collection = GetMongoCollection(collectionPlan);
|
||||
var existing = await LoadDocumentAsync(collection, request.StorageUnit, request.Id, cancellationToken);
|
||||
ValidateExpectedVersion(request.StorageUnit, request.Id, request.ExpectedVersion, existing?.Version);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var createdAt = existing?.CreatedAt ?? now;
|
||||
var version = request.ExpectedVersion is null ? existing?.Version + 1 ?? 1 : request.ExpectedVersion.Value + 1;
|
||||
var saved = new StoredDocument(request.StorageUnit, request.Id, request.Content, version, createdAt, now);
|
||||
var document = CreateDocument(saved, request.IndexValues);
|
||||
|
||||
if (request.ExpectedVersion == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (MongoWriteException e) when (e.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
await ThrowConcurrencyExceptionAsync(collection, request.StorageUnit, request.Id, request.ExpectedVersion.Value, cancellationToken);
|
||||
}
|
||||
catch (MongoDuplicateKeyException)
|
||||
{
|
||||
await ThrowConcurrencyExceptionAsync(collection, request.StorageUnit, request.Id, request.ExpectedVersion.Value, cancellationToken);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
var result = await collection.ReplaceOneAsync(
|
||||
CreateSaveFilter(request),
|
||||
document,
|
||||
new ReplaceOptions { IsUpsert = request.ExpectedVersion is null },
|
||||
cancellationToken);
|
||||
|
||||
if (request.ExpectedVersion is not null && !DidExpectedWriteSucceed(result))
|
||||
await ThrowConcurrencyExceptionAsync(collection, request.StorageUnit, request.Id, request.ExpectedVersion.Value, cancellationToken);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public async Task<StoredDocument?> LoadAsync(string storageUnit, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var collectionPlan = GetCollectionPlan(storageUnit);
|
||||
return await LoadDocumentAsync(GetMongoCollection(collectionPlan), storageUnit, id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string storageUnit, string id, long? expectedVersion = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var collectionPlan = GetCollectionPlan(storageUnit);
|
||||
var collection = GetMongoCollection(collectionPlan);
|
||||
var existing = await LoadDocumentAsync(collection, storageUnit, id, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
ValidateExpectedVersion(storageUnit, id, expectedVersion, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
ValidateExpectedVersion(storageUnit, id, expectedVersion, existing.Version);
|
||||
var result = await collection.DeleteOneAsync(CreateDeleteFilter(id, expectedVersion), cancellationToken);
|
||||
if (expectedVersion is not null && result.DeletedCount == 0)
|
||||
await ThrowConcurrencyExceptionAsync(collection, storageUnit, id, expectedVersion.Value, cancellationToken);
|
||||
|
||||
return result.DeletedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StoredDocument>> QueryAsync(DocumentQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var collectionPlan = GetCollectionPlan(query.StorageUnit);
|
||||
_ = DocumentIndexMatcher.FindMatchingIndex(collectionPlan.Collection, query);
|
||||
var filters = query.Filters
|
||||
.OrderBy(x => x.Key, StringComparer.Ordinal)
|
||||
.Select(filter => Builders<BsonDocument>.Filter.Eq($"IndexValues.{filter.Key}", filter.Value is null ? BsonNull.Value : BsonValue.Create(filter.Value)))
|
||||
.ToList();
|
||||
var mongoFilter = Builders<BsonDocument>.Filter.And(filters);
|
||||
var documents = await GetMongoCollection(collectionPlan)
|
||||
.Find(mongoFilter)
|
||||
.Sort(Builders<BsonDocument>.Sort.Ascending("_id"))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return documents.Select(document => ReadDocument(document, query.StorageUnit)).ToList();
|
||||
}
|
||||
|
||||
private MongoDbCollectionPlan GetCollectionPlan(string storageUnit)
|
||||
{
|
||||
return _plan.Collections.SingleOrDefault(x => string.Equals(x.Collection.Name, storageUnit, StringComparison.Ordinal))
|
||||
?? throw new DocumentStoreValidationException($"Storage unit '{storageUnit}' is not declared in the persistence schema.");
|
||||
}
|
||||
|
||||
private IMongoCollection<BsonDocument> GetMongoCollection(MongoDbCollectionPlan collectionPlan)
|
||||
{
|
||||
return _database.GetCollection<BsonDocument>(collectionPlan.CollectionName);
|
||||
}
|
||||
|
||||
private async Task EnsureCollectionExistsAsync(string collectionName, CancellationToken cancellationToken)
|
||||
{
|
||||
using var cursor = await _database.ListCollectionNamesAsync(new ListCollectionNamesOptions
|
||||
{
|
||||
Filter = new BsonDocument("name", collectionName)
|
||||
}, cancellationToken);
|
||||
|
||||
var exists = await cursor.MoveNextAsync(cancellationToken) && cursor.Current.Any();
|
||||
if (!exists)
|
||||
await _database.CreateCollectionAsync(collectionName, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static void ValidateSaveRequest(DocumentCollection collection, SaveDocumentRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Id))
|
||||
throw new DocumentStoreValidationException("Document ID is required.");
|
||||
|
||||
var missingFields = collection.Indexes
|
||||
.SelectMany(x => x.Fields)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Where(field => !request.IndexValues.ContainsKey(field))
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (missingFields.Count > 0)
|
||||
throw new DocumentStoreValidationException($"Storage unit '{collection.Name}' requires index values for fields '{string.Join(", ", missingFields)}'.");
|
||||
}
|
||||
|
||||
private static void ValidateExpectedVersion(string storageUnit, string documentId, long? expectedVersion, long? actualVersion)
|
||||
{
|
||||
if (expectedVersion is null)
|
||||
return;
|
||||
|
||||
if (expectedVersion != (actualVersion ?? 0))
|
||||
throw new DocumentStoreConcurrencyException(storageUnit, documentId, expectedVersion, actualVersion);
|
||||
}
|
||||
|
||||
private static async Task<StoredDocument?> LoadDocumentAsync(IMongoCollection<BsonDocument> collection, string storageUnit, string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await collection.Find(Builders<BsonDocument>.Filter.Eq("_id", id)).FirstOrDefaultAsync(cancellationToken);
|
||||
return document is null ? null : ReadDocument(document, storageUnit);
|
||||
}
|
||||
|
||||
private static FilterDefinition<BsonDocument> CreateSaveFilter(SaveDocumentRequest request)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter;
|
||||
|
||||
if (request.ExpectedVersion is null)
|
||||
return filter.Eq("_id", request.Id);
|
||||
|
||||
return filter.And(filter.Eq("_id", request.Id), filter.Eq("Version", request.ExpectedVersion.Value));
|
||||
}
|
||||
|
||||
private static FilterDefinition<BsonDocument> CreateDeleteFilter(string id, long? expectedVersion)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", id);
|
||||
return expectedVersion is null ? filter : Builders<BsonDocument>.Filter.And(filter, Builders<BsonDocument>.Filter.Eq("Version", expectedVersion.Value));
|
||||
}
|
||||
|
||||
private static bool DidExpectedWriteSucceed(ReplaceOneResult result)
|
||||
{
|
||||
if (!result.IsAcknowledged)
|
||||
return true;
|
||||
|
||||
return result.MatchedCount > 0;
|
||||
}
|
||||
|
||||
private static async Task ThrowConcurrencyExceptionAsync(IMongoCollection<BsonDocument> collection, string storageUnit, string documentId, long expectedVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
var actual = await LoadDocumentAsync(collection, storageUnit, documentId, cancellationToken);
|
||||
throw new DocumentStoreConcurrencyException(storageUnit, documentId, expectedVersion, actual?.Version);
|
||||
}
|
||||
|
||||
private static BsonDocument CreateDocument(StoredDocument document, IReadOnlyDictionary<string, string?> indexValues)
|
||||
{
|
||||
return new BsonDocument
|
||||
{
|
||||
["_id"] = document.Id,
|
||||
["Content"] = document.Content,
|
||||
["Version"] = document.Version,
|
||||
["CreatedAt"] = new BsonDateTime(document.CreatedAt.UtcDateTime),
|
||||
["UpdatedAt"] = new BsonDateTime(document.UpdatedAt.UtcDateTime),
|
||||
["Data"] = ParseContent(document.Content),
|
||||
["IndexValues"] = new BsonDocument(indexValues.Select(x => new BsonElement(x.Key, x.Value is null ? BsonNull.Value : BsonValue.Create(x.Value))))
|
||||
};
|
||||
}
|
||||
|
||||
private static BsonValue ParseContent(string content)
|
||||
{
|
||||
return BsonDocument.TryParse(content, out var document) ? document : BsonValue.Create(content);
|
||||
}
|
||||
|
||||
private static StoredDocument ReadDocument(BsonDocument document, string storageUnit)
|
||||
{
|
||||
return new StoredDocument(
|
||||
StorageUnit: storageUnit,
|
||||
Id: document["_id"].AsString,
|
||||
Content: document["Content"].AsString,
|
||||
Version: document["Version"].ToInt64(),
|
||||
CreatedAt: document["CreatedAt"].ToUniversalTime(),
|
||||
UpdatedAt: document["UpdatedAt"].ToUniversalTime());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext.MongoDb;
|
||||
|
||||
public record MongoDbIndexPlan(
|
||||
string Name,
|
||||
IReadOnlyList<string> Fields,
|
||||
bool IsUnique);
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using Elsa.Persistence.VNext;
|
||||
using Elsa.Persistence.VNext.Physicalization;
|
||||
|
||||
namespace Elsa.Persistence.VNext.MongoDb.Physicalization;
|
||||
|
||||
public class MongoDbPhysicalizationPlanner : IPhysicalizationPlanner
|
||||
{
|
||||
public PhysicalizationPlan Plan(PersistenceSchema schema, StoragePhysicalizationPolicy policy)
|
||||
{
|
||||
if (policy.Target != PhysicalizationTarget.DedicatedDocumentCollection)
|
||||
throw new InvalidOperationException("MongoDB physicalization supports dedicated document collections only.");
|
||||
|
||||
PhysicalizationPolicyValidator.GetStorageUnit(schema, policy);
|
||||
|
||||
var collectionName = NormalizeName(policy.PhysicalName ?? policy.StorageUnit);
|
||||
var operations = new List<PhysicalizationOperation>
|
||||
{
|
||||
new("CreateCollection", $"Create dedicated MongoDB collection {collectionName} for {policy.StorageUnit}.")
|
||||
};
|
||||
|
||||
foreach (var index in policy.Indexes)
|
||||
{
|
||||
var fields = string.Join(",", index.Fields.Select(field => $"Data.{field}:1"));
|
||||
var uniqueness = index.IsUnique ? "unique" : "non-unique";
|
||||
operations.Add(new(
|
||||
"CreateIndex",
|
||||
$"Create {uniqueness} MongoDB index {index.Name} on {collectionName} ({fields})."));
|
||||
}
|
||||
|
||||
return new PhysicalizationPlan("MongoDB", policy, operations);
|
||||
}
|
||||
|
||||
private static string NormalizeName(string value)
|
||||
{
|
||||
var chars = value.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' ? c : '_').ToArray();
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>
|
||||
Experimental PostgreSQL provider for Elsa Persistence vNext.
|
||||
</Description>
|
||||
<PackageTags>elsa module persistence vnext postgresql experimental</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext\Elsa.Persistence.VNext.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext.Relational\Elsa.Persistence.VNext.Relational.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait ContinueOnCapturedContext="false" />
|
||||
</Weavers>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using Elsa.Persistence.VNext.Document;
|
||||
using Elsa.Persistence.VNext.Relational.Documents;
|
||||
using Npgsql;
|
||||
|
||||
namespace Elsa.Persistence.VNext.PostgreSql;
|
||||
|
||||
public class PostgreSqlDocumentStore : RelationalDocumentStore
|
||||
{
|
||||
public PostgreSqlDocumentStore(NpgsqlConnection connection, PersistenceSchema schema) : base(connection, schema, new PostgreSqlDocumentStoreDialect())
|
||||
{
|
||||
}
|
||||
|
||||
public PostgreSqlDocumentStore(NpgsqlConnection connection, DocumentDatabasePlan plan) : base(connection, plan, new PostgreSqlDocumentStoreDialect())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using Elsa.Persistence.VNext.Relational.Documents;
|
||||
|
||||
namespace Elsa.Persistence.VNext.PostgreSql;
|
||||
|
||||
public class PostgreSqlDocumentStoreDialect : RelationalDocumentStoreDialect
|
||||
{
|
||||
public override string Parameter(string name)
|
||||
{
|
||||
return $"@{name}";
|
||||
}
|
||||
|
||||
public override string QuoteIdentifier(string identifier)
|
||||
{
|
||||
return $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
|
||||
public override IReadOnlyList<string> CreateMaterializationLockStatements()
|
||||
{
|
||||
return [
|
||||
"SELECT pg_advisory_xact_lock(hashtext('Elsa.Persistence.VNext.Documents'));"
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Elsa.Persistence.VNext.Document;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Relational.Documents;
|
||||
|
||||
public class RelationalDocumentStore : IDocumentStore
|
||||
{
|
||||
private readonly DbConnection _connection;
|
||||
private readonly DocumentDatabasePlan _plan;
|
||||
private readonly RelationalDocumentStoreDialect _dialect;
|
||||
|
||||
public RelationalDocumentStore(DbConnection connection, PersistenceSchema schema, RelationalDocumentStoreDialect dialect) : this(connection, new DocumentDatabasePlanner().Plan(schema), dialect)
|
||||
{
|
||||
}
|
||||
|
||||
public RelationalDocumentStore(DbConnection connection, DocumentDatabasePlan plan, RelationalDocumentStoreDialect dialect)
|
||||
{
|
||||
_connection = connection;
|
||||
_plan = plan;
|
||||
_dialect = dialect;
|
||||
}
|
||||
|
||||
public async Task MaterializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await OpenAsync(cancellationToken);
|
||||
|
||||
await using var transaction = await _connection.BeginTransactionAsync(cancellationToken);
|
||||
foreach (var statement in _dialect.CreateMaterializationLockStatements())
|
||||
await ExecuteAsync(transaction, statement, cancellationToken);
|
||||
|
||||
foreach (var statement in _dialect.CreateMaterializationStatements())
|
||||
await ExecuteAsync(transaction, statement, cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<StoredDocument> SaveAsync(SaveDocumentRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var collection = GetCollection(request.StorageUnit);
|
||||
ValidateSaveRequest(collection, request);
|
||||
await OpenAsync(cancellationToken);
|
||||
|
||||
await using var transaction = await _connection.BeginTransactionAsync(cancellationToken);
|
||||
var existing = await LoadAsync(transaction, request.StorageUnit, request.Id, cancellationToken);
|
||||
ValidateExpectedVersion(request.StorageUnit, request.Id, request.ExpectedVersion, existing?.Version);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var createdAt = existing?.CreatedAt ?? now;
|
||||
var version = existing is null ? 1 : existing.Version + 1;
|
||||
var saved = new StoredDocument(request.StorageUnit, request.Id, request.Content, version, createdAt, now);
|
||||
|
||||
await UpsertDocumentAsync(transaction, saved, cancellationToken);
|
||||
await ReplaceIndexValuesAsync(transaction, collection, request, cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
public async Task<StoredDocument?> LoadAsync(string storageUnit, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = GetCollection(storageUnit);
|
||||
await OpenAsync(cancellationToken);
|
||||
return await LoadAsync(null, storageUnit, id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string storageUnit, string id, long? expectedVersion = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = GetCollection(storageUnit);
|
||||
await OpenAsync(cancellationToken);
|
||||
|
||||
await using var transaction = await _connection.BeginTransactionAsync(cancellationToken);
|
||||
var existing = await LoadAsync(transaction, storageUnit, id, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
ValidateExpectedVersion(storageUnit, id, expectedVersion, null);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return false;
|
||||
}
|
||||
|
||||
ValidateExpectedVersion(storageUnit, id, expectedVersion, existing.Version);
|
||||
await DeleteIndexValuesAsync(transaction, storageUnit, id, cancellationToken);
|
||||
await DeleteDocumentAsync(transaction, storageUnit, id, cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StoredDocument>> QueryAsync(DocumentQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var collection = GetCollection(query.StorageUnit);
|
||||
var index = _dialect.FindMatchingIndex(collection, query);
|
||||
await OpenAsync(cancellationToken);
|
||||
|
||||
await using var transaction = await _connection.BeginTransactionAsync(cancellationToken);
|
||||
var documentIds = await QueryDocumentIdsAsync(transaction, query, index, cancellationToken);
|
||||
var documents = new List<StoredDocument>();
|
||||
|
||||
foreach (var documentId in documentIds)
|
||||
{
|
||||
var document = await LoadAsync(transaction, query.StorageUnit, documentId, cancellationToken);
|
||||
if (document is not null)
|
||||
documents.Add(document);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return documents;
|
||||
}
|
||||
|
||||
private DocumentCollection GetCollection(string storageUnit)
|
||||
{
|
||||
return _plan.Collections.SingleOrDefault(x => string.Equals(x.Name, storageUnit, StringComparison.Ordinal))
|
||||
?? throw new DocumentStoreValidationException($"Storage unit '{storageUnit}' is not declared in the persistence schema.");
|
||||
}
|
||||
|
||||
private static void ValidateSaveRequest(DocumentCollection collection, SaveDocumentRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Id))
|
||||
throw new DocumentStoreValidationException("Document ID is required.");
|
||||
|
||||
var missingFields = collection.Indexes
|
||||
.SelectMany(x => x.Fields)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Where(field => !request.IndexValues.ContainsKey(field))
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (missingFields.Count > 0)
|
||||
throw new DocumentStoreValidationException($"Storage unit '{collection.Name}' requires index values for fields '{string.Join(", ", missingFields)}'.");
|
||||
}
|
||||
|
||||
private static void ValidateExpectedVersion(string storageUnit, string documentId, long? expectedVersion, long? actualVersion)
|
||||
{
|
||||
if (expectedVersion is null)
|
||||
return;
|
||||
|
||||
if (expectedVersion != (actualVersion ?? 0))
|
||||
throw new DocumentStoreConcurrencyException(storageUnit, documentId, expectedVersion, actualVersion);
|
||||
}
|
||||
|
||||
private async Task OpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection.State != ConnectionState.Open)
|
||||
await _connection.OpenAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<StoredDocument?> LoadAsync(DbTransaction? transaction, string storageUnit, string id, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = _dialect.RenderSelectDocumentSql();
|
||||
AddParameter(command, "storageUnit", storageUnit);
|
||||
AddParameter(command, "id", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
return null;
|
||||
|
||||
return ReadDocument(reader);
|
||||
}
|
||||
|
||||
private static StoredDocument ReadDocument(DbDataReader reader)
|
||||
{
|
||||
return new StoredDocument(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetInt64(3),
|
||||
DateTimeOffset.Parse(reader.GetString(4), CultureInfo.InvariantCulture),
|
||||
DateTimeOffset.Parse(reader.GetString(5), CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private async Task UpsertDocumentAsync(DbTransaction transaction, StoredDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = _dialect.RenderUpsertDocumentSql();
|
||||
AddParameter(command, "storageUnit", document.StorageUnit);
|
||||
AddParameter(command, "id", document.Id);
|
||||
AddParameter(command, "content", document.Content);
|
||||
AddParameter(command, "version", document.Version);
|
||||
AddParameter(command, "createdAt", _dialect.ConvertTimestamp(document.CreatedAt));
|
||||
AddParameter(command, "updatedAt", _dialect.ConvertTimestamp(document.UpdatedAt));
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ReplaceIndexValuesAsync(DbTransaction transaction, DocumentCollection collection, SaveDocumentRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await DeleteIndexValuesAsync(transaction, request.StorageUnit, request.Id, cancellationToken);
|
||||
|
||||
foreach (var index in collection.Indexes)
|
||||
{
|
||||
foreach (var field in index.Fields)
|
||||
await InsertIndexValueAsync(transaction, request.StorageUnit, index.Name, field, request.IndexValues[field], request.Id, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InsertIndexValueAsync(DbTransaction transaction, string storageUnit, string indexName, string fieldName, string? fieldValue, string documentId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = _dialect.RenderInsertIndexValueSql();
|
||||
AddParameter(command, "storageUnit", storageUnit);
|
||||
AddParameter(command, "indexName", indexName);
|
||||
AddParameter(command, "fieldName", fieldName);
|
||||
AddParameter(command, "fieldValue", _dialect.ConvertStoredValue(fieldValue));
|
||||
AddParameter(command, "documentId", documentId);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> QueryDocumentIdsAsync(DbTransaction transaction, DocumentQuery query, DocumentIndex index, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
AddParameter(command, "storageUnit", query.StorageUnit);
|
||||
AddParameter(command, "indexName", index.Name);
|
||||
|
||||
var clauses = new List<string>();
|
||||
var parameterIndex = 0;
|
||||
foreach (var filter in query.Filters.OrderBy(x => x.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var fieldName = $"field{parameterIndex}";
|
||||
var fieldValue = $"value{parameterIndex}";
|
||||
clauses.Add(_dialect.RenderFieldFilter(_dialect.Parameter(fieldName), _dialect.Parameter(fieldValue)));
|
||||
AddParameter(command, fieldName, filter.Key);
|
||||
AddParameter(command, fieldValue, _dialect.ConvertStoredValue(filter.Value));
|
||||
parameterIndex++;
|
||||
}
|
||||
|
||||
command.CommandText = _dialect.RenderQueryDocumentIdsSql(clauses);
|
||||
AddParameter(command, "fieldCount", query.Filters.Count);
|
||||
|
||||
var documentIds = new List<string>();
|
||||
await using (command)
|
||||
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
documentIds.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return documentIds;
|
||||
}
|
||||
|
||||
private async Task DeleteIndexValuesAsync(DbTransaction transaction, string storageUnit, string documentId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = _dialect.RenderDeleteIndexValuesSql();
|
||||
AddParameter(command, "storageUnit", storageUnit);
|
||||
AddParameter(command, "documentId", documentId);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task DeleteDocumentAsync(DbTransaction transaction, string storageUnit, string documentId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = _dialect.RenderDeleteDocumentSql();
|
||||
AddParameter(command, "storageUnit", storageUnit);
|
||||
AddParameter(command, "documentId", documentId);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ExecuteAsync(DbTransaction transaction, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void AddParameter(DbCommand command, string name, object? value)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = _dialect.Parameter(name);
|
||||
parameter.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
using Elsa.Persistence.VNext.Document;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Relational.Documents;
|
||||
|
||||
public abstract class RelationalDocumentStoreDialect
|
||||
{
|
||||
public string DocumentsTable => QuoteIdentifier("ElsaDocuments");
|
||||
public string IndexValuesTable => QuoteIdentifier("ElsaDocumentIndexValues");
|
||||
|
||||
public abstract string Parameter(string name);
|
||||
|
||||
public abstract string QuoteIdentifier(string identifier);
|
||||
|
||||
public virtual IReadOnlyList<string> CreateMaterializationLockStatements()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public virtual IReadOnlyList<string> CreateMaterializationStatements()
|
||||
{
|
||||
return [
|
||||
$"""
|
||||
CREATE TABLE IF NOT EXISTS {DocumentsTable} (
|
||||
{QuoteIdentifier("StorageUnit")} TEXT NOT NULL,
|
||||
{QuoteIdentifier("Id")} TEXT NOT NULL,
|
||||
{QuoteIdentifier("Content")} TEXT NOT NULL,
|
||||
{QuoteIdentifier("Version")} BIGINT NOT NULL,
|
||||
{QuoteIdentifier("CreatedAt")} TEXT NOT NULL,
|
||||
{QuoteIdentifier("UpdatedAt")} TEXT NOT NULL,
|
||||
CONSTRAINT {QuoteIdentifier("PK_ElsaDocuments")} PRIMARY KEY ({QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("Id")})
|
||||
);
|
||||
""",
|
||||
$"""
|
||||
CREATE TABLE IF NOT EXISTS {IndexValuesTable} (
|
||||
{QuoteIdentifier("StorageUnit")} TEXT NOT NULL,
|
||||
{QuoteIdentifier("IndexName")} TEXT NOT NULL,
|
||||
{QuoteIdentifier("FieldName")} TEXT NOT NULL,
|
||||
{QuoteIdentifier("FieldValue")} TEXT,
|
||||
{QuoteIdentifier("DocumentId")} TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
$"""
|
||||
CREATE INDEX IF NOT EXISTS {QuoteIdentifier("IX_ElsaDocumentIndexValues_Lookup")}
|
||||
ON {IndexValuesTable} ({QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("IndexName")}, {QuoteIdentifier("FieldName")}, {QuoteIdentifier("FieldValue")}, {QuoteIdentifier("DocumentId")});
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
public virtual string RenderSelectDocumentSql()
|
||||
{
|
||||
return $"""
|
||||
SELECT {QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("Id")}, {QuoteIdentifier("Content")}, {QuoteIdentifier("Version")}, {QuoteIdentifier("CreatedAt")}, {QuoteIdentifier("UpdatedAt")}
|
||||
FROM {DocumentsTable}
|
||||
WHERE {QuoteIdentifier("StorageUnit")} = {Parameter("storageUnit")} AND {QuoteIdentifier("Id")} = {Parameter("id")};
|
||||
""";
|
||||
}
|
||||
|
||||
public virtual string RenderUpsertDocumentSql()
|
||||
{
|
||||
return $"""
|
||||
INSERT INTO {DocumentsTable} ({QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("Id")}, {QuoteIdentifier("Content")}, {QuoteIdentifier("Version")}, {QuoteIdentifier("CreatedAt")}, {QuoteIdentifier("UpdatedAt")})
|
||||
VALUES ({Parameter("storageUnit")}, {Parameter("id")}, {Parameter("content")}, {Parameter("version")}, {Parameter("createdAt")}, {Parameter("updatedAt")})
|
||||
ON CONFLICT({QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("Id")}) DO UPDATE SET
|
||||
{QuoteIdentifier("Content")} = excluded.{QuoteIdentifier("Content")},
|
||||
{QuoteIdentifier("Version")} = excluded.{QuoteIdentifier("Version")},
|
||||
{QuoteIdentifier("UpdatedAt")} = excluded.{QuoteIdentifier("UpdatedAt")};
|
||||
""";
|
||||
}
|
||||
|
||||
public virtual string RenderDeleteIndexValuesSql()
|
||||
{
|
||||
return $"""DELETE FROM {IndexValuesTable} WHERE {QuoteIdentifier("StorageUnit")} = {Parameter("storageUnit")} AND {QuoteIdentifier("DocumentId")} = {Parameter("documentId")};""";
|
||||
}
|
||||
|
||||
public virtual string RenderInsertIndexValueSql()
|
||||
{
|
||||
return $"""
|
||||
INSERT INTO {IndexValuesTable} ({QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("IndexName")}, {QuoteIdentifier("FieldName")}, {QuoteIdentifier("FieldValue")}, {QuoteIdentifier("DocumentId")})
|
||||
VALUES ({Parameter("storageUnit")}, {Parameter("indexName")}, {Parameter("fieldName")}, {Parameter("fieldValue")}, {Parameter("documentId")});
|
||||
""";
|
||||
}
|
||||
|
||||
public virtual string RenderDeleteDocumentSql()
|
||||
{
|
||||
return $"""DELETE FROM {DocumentsTable} WHERE {QuoteIdentifier("StorageUnit")} = {Parameter("storageUnit")} AND {QuoteIdentifier("Id")} = {Parameter("documentId")};""";
|
||||
}
|
||||
|
||||
public virtual string RenderQueryDocumentIdsSql(IReadOnlyList<string> filters)
|
||||
{
|
||||
return $"""
|
||||
SELECT {QuoteIdentifier("DocumentId")}
|
||||
FROM {IndexValuesTable}
|
||||
WHERE {QuoteIdentifier("StorageUnit")} = {Parameter("storageUnit")}
|
||||
AND {QuoteIdentifier("IndexName")} = {Parameter("indexName")}
|
||||
AND ({string.Join(" OR ", filters)})
|
||||
GROUP BY {QuoteIdentifier("DocumentId")}
|
||||
HAVING COUNT(DISTINCT {QuoteIdentifier("FieldName")}) = {Parameter("fieldCount")}
|
||||
ORDER BY {QuoteIdentifier("DocumentId")};
|
||||
""";
|
||||
}
|
||||
|
||||
public virtual string RenderFieldFilter(string fieldParameter, string valueParameter)
|
||||
{
|
||||
return $"""({QuoteIdentifier("FieldName")} = {fieldParameter} AND ({QuoteIdentifier("FieldValue")} = {valueParameter} OR ({QuoteIdentifier("FieldValue")} IS NULL AND {valueParameter} IS NULL)))""";
|
||||
}
|
||||
|
||||
public virtual object? ConvertStoredValue(string? value)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
public virtual object ConvertTimestamp(DateTimeOffset value)
|
||||
{
|
||||
return value.ToString("O");
|
||||
}
|
||||
|
||||
public DocumentIndex FindMatchingIndex(DocumentCollection collection, DocumentQuery query)
|
||||
{
|
||||
return DocumentIndexMatcher.FindMatchingIndex(collection, query);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>
|
||||
Experimental shared relational document store infrastructure for Elsa Persistence vNext.
|
||||
</Description>
|
||||
<PackageTags>elsa module persistence vnext relational experimental</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext\Elsa.Persistence.VNext.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait ContinueOnCapturedContext="false" />
|
||||
</Weavers>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Runtime.Contracts;
|
||||
|
||||
public interface IRuntimeEntityManager
|
||||
{
|
||||
Task<RuntimeEntityDefinition> SaveDraftAsync(RuntimeEntityDefinition definition, CancellationToken cancellationToken = default);
|
||||
Task<RuntimeEntityDefinition?> GetDefinitionAsync(string name, CancellationToken cancellationToken = default);
|
||||
Task<RuntimeEntityDefinition> PublishAsync(string name, CancellationToken cancellationToken = default);
|
||||
Task<RuntimeEntityDefinition> RetireAsync(string name, CancellationToken cancellationToken = default);
|
||||
Task<RuntimeEntityInstance> SaveInstanceAsync(RuntimeEntityInstance instance, CancellationToken cancellationToken = default);
|
||||
Task<RuntimeEntityInstance?> GetInstanceAsync(string definitionName, string id, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteInstanceAsync(string definitionName, string id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RuntimeEntityInstance>> QueryInstancesAsync(string definitionName, string indexedFieldName, object? value, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<RuntimeEntityAuditRecord>> ListAuditAsync(string subjectId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>
|
||||
Experimental runtime-defined entity support for Elsa Persistence vNext.
|
||||
</Description>
|
||||
<PackageTags>elsa module persistence vnext experimental runtime entities</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext\Elsa.Persistence.VNext.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Evaluation;
|
||||
|
||||
public record WorkflowRuntimePersistenceCandidate(
|
||||
string Name,
|
||||
WorkflowRuntimePersistenceArea Area,
|
||||
WorkflowRuntimePersistenceWorkload Workload,
|
||||
bool RequiresDistributedLocking,
|
||||
bool RequiresOrderedAppend,
|
||||
bool HasRepresentativeBenchmark,
|
||||
bool HasProviderContractTests);
|
||||
|
||||
public enum WorkflowRuntimePersistenceArea
|
||||
{
|
||||
Management,
|
||||
Bookmarks,
|
||||
Triggers,
|
||||
ExecutionLogs,
|
||||
ActivityExecutions,
|
||||
Outbox
|
||||
}
|
||||
|
||||
public enum WorkflowRuntimePersistenceWorkload
|
||||
{
|
||||
Metadata,
|
||||
InteractiveQuery,
|
||||
HotLookup,
|
||||
Queue,
|
||||
AppendOnlyLog
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Evaluation;
|
||||
|
||||
public record WorkflowRuntimePersistenceDecision(
|
||||
string CandidateName,
|
||||
WorkflowRuntimePersistenceDecisionKind Kind,
|
||||
IReadOnlyList<string> Reasons,
|
||||
IReadOnlyList<string> RequiredEvidence);
|
||||
|
||||
public enum WorkflowRuntimePersistenceDecisionKind
|
||||
{
|
||||
AdoptVNext,
|
||||
AdoptWithPhysicalization,
|
||||
DeferUntilBenchmarked,
|
||||
KeepSpecializedProvider
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Evaluation;
|
||||
|
||||
public class WorkflowRuntimePersistenceEvaluator
|
||||
{
|
||||
public WorkflowRuntimePersistenceDecision Evaluate(WorkflowRuntimePersistenceCandidate candidate)
|
||||
{
|
||||
if (candidate.RequiresOrderedAppend)
|
||||
{
|
||||
return Decide(
|
||||
candidate,
|
||||
WorkflowRuntimePersistenceDecisionKind.KeepSpecializedProvider,
|
||||
"The workload requires ordered append semantics.",
|
||||
"Provider-specific batching, retention, and replay behavior must be benchmarked before vNext can own this store.");
|
||||
}
|
||||
|
||||
if (candidate.Workload == WorkflowRuntimePersistenceWorkload.Queue)
|
||||
{
|
||||
return Decide(
|
||||
candidate,
|
||||
WorkflowRuntimePersistenceDecisionKind.KeepSpecializedProvider,
|
||||
"Queue workloads need lease, retry, dead-letter, and visibility-timeout semantics.",
|
||||
"Provider-specific queue/concurrency contract tests are required before replacing the current store.");
|
||||
}
|
||||
|
||||
if (candidate.Workload == WorkflowRuntimePersistenceWorkload.HotLookup && candidate.RequiresDistributedLocking)
|
||||
{
|
||||
return candidate.HasRepresentativeBenchmark && candidate.HasProviderContractTests
|
||||
? Decide(
|
||||
candidate,
|
||||
WorkflowRuntimePersistenceDecisionKind.AdoptWithPhysicalization,
|
||||
"The hot lookup has benchmark and provider contract evidence.",
|
||||
"A physicalization policy and operational rollback plan must be attached to the store.")
|
||||
: Decide(
|
||||
candidate,
|
||||
WorkflowRuntimePersistenceDecisionKind.DeferUntilBenchmarked,
|
||||
"Hot distributed lookups need benchmark and lock/concurrency evidence.",
|
||||
"Add representative provider benchmarks and distributed-locking contract tests.");
|
||||
}
|
||||
|
||||
if (!candidate.HasProviderContractTests)
|
||||
{
|
||||
return Decide(
|
||||
candidate,
|
||||
WorkflowRuntimePersistenceDecisionKind.DeferUntilBenchmarked,
|
||||
"Provider contract tests are missing.",
|
||||
"Add cross-provider save/query/concurrency tests for the store shape.");
|
||||
}
|
||||
|
||||
return candidate.Workload == WorkflowRuntimePersistenceWorkload.Metadata
|
||||
? Decide(
|
||||
candidate,
|
||||
WorkflowRuntimePersistenceDecisionKind.AdoptVNext,
|
||||
"Metadata workloads fit the portable document/index model.",
|
||||
"Keep provider contract tests in CI.")
|
||||
: Decide(
|
||||
candidate,
|
||||
WorkflowRuntimePersistenceDecisionKind.AdoptWithPhysicalization,
|
||||
"Interactive queries can use vNext if declared indexes are physicalized where needed.",
|
||||
"Add provider benchmarks for the most common filters before production rollout.");
|
||||
}
|
||||
|
||||
private static WorkflowRuntimePersistenceDecision Decide(
|
||||
WorkflowRuntimePersistenceCandidate candidate,
|
||||
WorkflowRuntimePersistenceDecisionKind kind,
|
||||
string reason,
|
||||
string requiredEvidence)
|
||||
{
|
||||
return new WorkflowRuntimePersistenceDecision(candidate.Name, kind, [reason], [requiredEvidence]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using Elsa.Persistence.VNext.Contracts;
|
||||
using Elsa.Persistence.VNext.Runtime.Contracts;
|
||||
using Elsa.Persistence.VNext.Runtime.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Runtime.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddRuntimeEntitiesVNext(this IServiceCollection services, Action<RuntimeEntityOptions>? configureOptions = null)
|
||||
{
|
||||
if (configureOptions is not null)
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.AddOptions<RuntimeEntityOptions>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPersistenceSchemaProvider, RuntimeEntityPersistenceSchemaProvider>());
|
||||
services.TryAddSingleton<RuntimeEntityDefinitionValidator>();
|
||||
services.TryAddSingleton<IRuntimeEntityManager, RuntimeEntityManager>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait/>
|
||||
</Weavers>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
public class RuntimeEntityAuditRecord
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string SubjectType { get; set; } = default!;
|
||||
public string SubjectId { get; set; } = default!;
|
||||
public string Action { get; set; } = default!;
|
||||
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
public class RuntimeEntityDefinition
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Name { get; set; } = default!;
|
||||
public int Version { get; set; } = 1;
|
||||
public RuntimeEntityDefinitionStatus Status { get; set; } = RuntimeEntityDefinitionStatus.Draft;
|
||||
public List<RuntimeEntityFieldDefinition> Fields { get; set; } = [];
|
||||
public List<RuntimeEntityIndexDefinition> Indexes { get; set; } = [];
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
public enum RuntimeEntityDefinitionStatus
|
||||
{
|
||||
Draft,
|
||||
Published,
|
||||
Retired
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
public record RuntimeEntityFieldDefinition(
|
||||
string Name,
|
||||
RuntimeEntityFieldType Type,
|
||||
bool IsRequired = false);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
public enum RuntimeEntityFieldType
|
||||
{
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
DateTimeOffset,
|
||||
Json
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
public record RuntimeEntityIndexDefinition(
|
||||
string Name,
|
||||
string FieldName);
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
public class RuntimeEntityInstance
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string DefinitionName { get; set; } = default!;
|
||||
public int DefinitionVersion { get; set; }
|
||||
public Dictionary<string, object?> Data { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext.Runtime;
|
||||
|
||||
public class RuntimeEntityOptions
|
||||
{
|
||||
public int MaxIndexedFields { get; set; } = 4;
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using Elsa.Persistence.VNext.Builders;
|
||||
using Elsa.Persistence.VNext.Contracts;
|
||||
using Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Runtime;
|
||||
|
||||
public class RuntimeEntityPersistenceSchemaProvider : IPersistenceSchemaProvider
|
||||
{
|
||||
public const string DefinitionsStorageUnit = "RuntimeEntityDefinitions";
|
||||
public const string InstancesStorageUnit = "RuntimeEntityInstances";
|
||||
public const string AuditStorageUnit = "RuntimeEntityAudit";
|
||||
public const int IndexedFieldSlotCount = 4;
|
||||
|
||||
public PersistenceSchema DescribeSchema()
|
||||
{
|
||||
var builder = new PersistenceSchemaBuilder("Elsa.RuntimeEntities").Version(1)
|
||||
.StorageUnit(DefinitionsStorageUnit, storage => storage
|
||||
.RequiredField(nameof(RuntimeEntityDefinition.Id), PersistenceColumnType.String, 450)
|
||||
.RequiredField(nameof(RuntimeEntityDefinition.Name), PersistenceColumnType.String, 200)
|
||||
.RequiredField(nameof(RuntimeEntityDefinition.Version), PersistenceColumnType.Int64)
|
||||
.RequiredField(nameof(RuntimeEntityDefinition.Status), PersistenceColumnType.String, 32)
|
||||
.RequiredField(nameof(RuntimeEntityDefinition.Fields), PersistenceColumnType.Json)
|
||||
.RequiredField(nameof(RuntimeEntityDefinition.Indexes), PersistenceColumnType.Json)
|
||||
.RequiredField(nameof(RuntimeEntityDefinition.CreatedAt), PersistenceColumnType.DateTimeOffset)
|
||||
.Field(nameof(RuntimeEntityDefinition.UpdatedAt), PersistenceColumnType.DateTimeOffset)
|
||||
.Key("PK_RuntimeEntityDefinitions", nameof(RuntimeEntityDefinition.Id))
|
||||
.Index("IX_RuntimeEntityDefinitions_Name", nameof(RuntimeEntityDefinition.Name), unique: true)
|
||||
.Index("IX_RuntimeEntityDefinitions_Status", nameof(RuntimeEntityDefinition.Status)),
|
||||
@namespace: "Elsa")
|
||||
.StorageUnit(InstancesStorageUnit, storage =>
|
||||
{
|
||||
storage
|
||||
.RequiredField(nameof(RuntimeEntityInstance.Id), PersistenceColumnType.String, 450)
|
||||
.RequiredField(nameof(RuntimeEntityInstance.DefinitionName), PersistenceColumnType.String, 200)
|
||||
.RequiredField(nameof(RuntimeEntityInstance.DefinitionVersion), PersistenceColumnType.Int64)
|
||||
.RequiredField(nameof(RuntimeEntityInstance.Data), PersistenceColumnType.Json)
|
||||
.RequiredField(nameof(RuntimeEntityInstance.CreatedAt), PersistenceColumnType.DateTimeOffset)
|
||||
.Field(nameof(RuntimeEntityInstance.UpdatedAt), PersistenceColumnType.DateTimeOffset)
|
||||
.Key("PK_RuntimeEntityInstances", nameof(RuntimeEntityInstance.Id))
|
||||
.Index("IX_RuntimeEntityInstances_DefinitionName", nameof(RuntimeEntityInstance.DefinitionName));
|
||||
|
||||
for (var slot = 1; slot <= IndexedFieldSlotCount; slot++)
|
||||
{
|
||||
storage
|
||||
.RequiredField($"Index{slot}Name", PersistenceColumnType.String, 200)
|
||||
.Field($"Index{slot}Value", PersistenceColumnType.String, length: 450)
|
||||
.Index($"IX_RuntimeEntityInstances_Index{slot}", [nameof(RuntimeEntityInstance.DefinitionName), $"Index{slot}Name", $"Index{slot}Value"]);
|
||||
}
|
||||
}, @namespace: "Elsa")
|
||||
.StorageUnit(AuditStorageUnit, storage => storage
|
||||
.RequiredField(nameof(RuntimeEntityAuditRecord.Id), PersistenceColumnType.String, 450)
|
||||
.RequiredField(nameof(RuntimeEntityAuditRecord.SubjectType), PersistenceColumnType.String, 100)
|
||||
.RequiredField(nameof(RuntimeEntityAuditRecord.SubjectId), PersistenceColumnType.String, 450)
|
||||
.RequiredField(nameof(RuntimeEntityAuditRecord.Action), PersistenceColumnType.String, 100)
|
||||
.RequiredField(nameof(RuntimeEntityAuditRecord.Timestamp), PersistenceColumnType.DateTimeOffset)
|
||||
.Field(nameof(RuntimeEntityAuditRecord.Message), PersistenceColumnType.Text)
|
||||
.Key("PK_RuntimeEntityAudit", nameof(RuntimeEntityAuditRecord.Id))
|
||||
.Index("IX_RuntimeEntityAudit_SubjectId", nameof(RuntimeEntityAuditRecord.SubjectId))
|
||||
.Index("IX_RuntimeEntityAudit_Subject", [nameof(RuntimeEntityAuditRecord.SubjectType), nameof(RuntimeEntityAuditRecord.SubjectId)]),
|
||||
@namespace: "Elsa");
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
using Elsa.Persistence.VNext.Runtime.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Runtime.Services;
|
||||
|
||||
public class RuntimeEntityDefinitionValidator(IOptions<RuntimeEntityOptions> options)
|
||||
{
|
||||
public void Validate(RuntimeEntityDefinition definition)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(definition.Name))
|
||||
throw new InvalidOperationException("Runtime entity definition name is required.");
|
||||
|
||||
if (definition.Fields.Count == 0)
|
||||
throw new InvalidOperationException($"Runtime entity definition '{definition.Name}' must declare at least one field.");
|
||||
|
||||
var duplicateField = definition.Fields.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(x => x.Count() > 1);
|
||||
if (duplicateField is not null)
|
||||
throw new InvalidOperationException($"Runtime entity definition '{definition.Name}' declares field '{duplicateField.Key}' more than once.");
|
||||
|
||||
var maxIndexedFields = Math.Min(options.Value.MaxIndexedFields, RuntimeEntityPersistenceSchemaProvider.IndexedFieldSlotCount);
|
||||
if (definition.Indexes.Count > maxIndexedFields)
|
||||
throw new InvalidOperationException($"Runtime entity definition '{definition.Name}' declares {definition.Indexes.Count} indexes, but only {maxIndexedFields} runtime index slots are available.");
|
||||
|
||||
var fields = definition.Fields.Select(x => x.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var index in definition.Indexes)
|
||||
{
|
||||
if (!fields.Contains(index.FieldName))
|
||||
throw new InvalidOperationException($"Runtime entity definition '{definition.Name}' index '{index.Name}' references unknown field '{index.FieldName}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public void ValidateInstance(RuntimeEntityDefinition definition, RuntimeEntityInstance instance)
|
||||
{
|
||||
var dataKeys = instance.Data.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var missingField = definition.Fields.FirstOrDefault(x => x.IsRequired && !dataKeys.Contains(x.Name));
|
||||
if (missingField is not null)
|
||||
throw new InvalidOperationException($"Runtime entity instance '{instance.Id}' is missing required field '{missingField.Name}'.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Elsa.Persistence.VNext.Document;
|
||||
using Elsa.Persistence.VNext.Runtime.Contracts;
|
||||
using Elsa.Persistence.VNext.Runtime.Models;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Runtime.Services;
|
||||
|
||||
public class RuntimeEntityManager(IDocumentStore documentStore, RuntimeEntityDefinitionValidator validator) : IRuntimeEntityManager
|
||||
{
|
||||
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public async Task<RuntimeEntityDefinition> SaveDraftAsync(RuntimeEntityDefinition definition, CancellationToken cancellationToken = default)
|
||||
{
|
||||
definition.Name = NormalizeName(definition.Name);
|
||||
definition.Status = RuntimeEntityDefinitionStatus.Draft;
|
||||
validator.Validate(definition);
|
||||
|
||||
var existing = await LoadDefinitionDocumentAsync(definition.Name, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
definition.CreatedAt = existing?.Definition.CreatedAt ?? definition.CreatedAt;
|
||||
definition.UpdatedAt = now;
|
||||
|
||||
await SaveDefinitionAsync(definition, existing?.Document.Version ?? 0, cancellationToken);
|
||||
await AppendAuditAsync("RuntimeEntityDefinition", definition.Name, "DraftSaved", $"Draft version {definition.Version} saved.", cancellationToken);
|
||||
return definition;
|
||||
}
|
||||
|
||||
public async Task<RuntimeEntityDefinition?> GetDefinitionAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await LoadDefinitionDocumentAsync(name, cancellationToken);
|
||||
return result?.Definition;
|
||||
}
|
||||
|
||||
public async Task<RuntimeEntityDefinition> PublishAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await LoadDefinitionDocumentAsync(name, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Runtime entity definition '{name}' was not found.");
|
||||
|
||||
existing.Definition.Status = RuntimeEntityDefinitionStatus.Published;
|
||||
existing.Definition.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
validator.Validate(existing.Definition);
|
||||
|
||||
await SaveDefinitionAsync(existing.Definition, existing.Document.Version, cancellationToken);
|
||||
await AppendAuditAsync("RuntimeEntityDefinition", existing.Definition.Name, "Published", $"Version {existing.Definition.Version} published.", cancellationToken);
|
||||
return existing.Definition;
|
||||
}
|
||||
|
||||
public async Task<RuntimeEntityDefinition> RetireAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await LoadDefinitionDocumentAsync(name, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Runtime entity definition '{name}' was not found.");
|
||||
|
||||
existing.Definition.Status = RuntimeEntityDefinitionStatus.Retired;
|
||||
existing.Definition.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await SaveDefinitionAsync(existing.Definition, existing.Document.Version, cancellationToken);
|
||||
await AppendAuditAsync("RuntimeEntityDefinition", existing.Definition.Name, "Retired", $"Version {existing.Definition.Version} retired.", cancellationToken);
|
||||
return existing.Definition;
|
||||
}
|
||||
|
||||
public async Task<RuntimeEntityInstance> SaveInstanceAsync(RuntimeEntityInstance instance, CancellationToken cancellationToken = default)
|
||||
{
|
||||
instance.DefinitionName = NormalizeName(instance.DefinitionName);
|
||||
var definition = await GetDefinitionAsync(instance.DefinitionName, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Runtime entity definition '{instance.DefinitionName}' was not found.");
|
||||
|
||||
if (definition.Status != RuntimeEntityDefinitionStatus.Published)
|
||||
throw new InvalidOperationException($"Runtime entity definition '{definition.Name}' is not published.");
|
||||
|
||||
validator.ValidateInstance(definition, instance);
|
||||
instance.DefinitionVersion = definition.Version;
|
||||
|
||||
var existing = await LoadInstanceDocumentAsync(instance.DefinitionName, instance.Id, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
instance.CreatedAt = existing?.Instance.CreatedAt ?? instance.CreatedAt;
|
||||
instance.UpdatedAt = now;
|
||||
|
||||
var request = new SaveDocumentRequest(
|
||||
RuntimeEntityPersistenceSchemaProvider.InstancesStorageUnit,
|
||||
CreateInstanceDocumentId(instance.DefinitionName, instance.Id),
|
||||
JsonSerializer.Serialize(instance, _jsonOptions),
|
||||
CreateInstanceIndexValues(definition, instance),
|
||||
existing?.Document.Version ?? 0);
|
||||
|
||||
await documentStore.SaveAsync(request, cancellationToken);
|
||||
await AppendAuditAsync("RuntimeEntityInstance", CreateInstanceDocumentId(instance.DefinitionName, instance.Id), existing is null ? "Created" : "Updated", null, cancellationToken);
|
||||
return instance;
|
||||
}
|
||||
|
||||
public async Task<RuntimeEntityInstance?> GetInstanceAsync(string definitionName, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await LoadInstanceDocumentAsync(definitionName, id, cancellationToken);
|
||||
return result?.Instance;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteInstanceAsync(string definitionName, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var documentId = CreateInstanceDocumentId(definitionName, id);
|
||||
var deleted = await documentStore.DeleteAsync(RuntimeEntityPersistenceSchemaProvider.InstancesStorageUnit, documentId, cancellationToken: cancellationToken);
|
||||
if (deleted)
|
||||
await AppendAuditAsync("RuntimeEntityInstance", documentId, "Deleted", null, cancellationToken);
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RuntimeEntityInstance>> QueryInstancesAsync(string definitionName, string indexedFieldName, object? value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedDefinitionName = NormalizeName(definitionName);
|
||||
var definition = await GetDefinitionAsync(normalizedDefinitionName, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Runtime entity definition '{definitionName}' was not found.");
|
||||
|
||||
if (!definition.Indexes.Any(x => string.Equals(x.FieldName, indexedFieldName, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new DocumentQueryNotIndexedException(RuntimeEntityPersistenceSchemaProvider.InstancesStorageUnit, [indexedFieldName]);
|
||||
|
||||
var results = new List<RuntimeEntityInstance>();
|
||||
for (var slot = 1; slot <= RuntimeEntityPersistenceSchemaProvider.IndexedFieldSlotCount; slot++)
|
||||
{
|
||||
var documents = await documentStore.QueryAsync(new DocumentQuery(
|
||||
RuntimeEntityPersistenceSchemaProvider.InstancesStorageUnit,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
[nameof(RuntimeEntityInstance.DefinitionName)] = normalizedDefinitionName,
|
||||
[$"Index{slot}Name"] = indexedFieldName,
|
||||
[$"Index{slot}Value"] = ConvertIndexValue(value)
|
||||
}), cancellationToken);
|
||||
|
||||
results.AddRange(documents.Select(DeserializeInstance));
|
||||
}
|
||||
|
||||
return results
|
||||
.GroupBy(x => x.Id, StringComparer.Ordinal)
|
||||
.Select(x => x.First())
|
||||
.OrderBy(x => x.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RuntimeEntityAuditRecord>> ListAuditAsync(string subjectId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var documents = await documentStore.QueryAsync(new DocumentQuery(
|
||||
RuntimeEntityPersistenceSchemaProvider.AuditStorageUnit,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
[nameof(RuntimeEntityAuditRecord.SubjectId)] = subjectId
|
||||
}), cancellationToken);
|
||||
|
||||
return documents.Select(DeserializeAudit).OrderBy(x => x.Timestamp).ToList();
|
||||
}
|
||||
|
||||
private async Task SaveDefinitionAsync(RuntimeEntityDefinition definition, long expectedVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new SaveDocumentRequest(
|
||||
RuntimeEntityPersistenceSchemaProvider.DefinitionsStorageUnit,
|
||||
NormalizeName(definition.Name),
|
||||
JsonSerializer.Serialize(definition, _jsonOptions),
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
[nameof(RuntimeEntityDefinition.Name)] = definition.Name,
|
||||
[nameof(RuntimeEntityDefinition.Status)] = definition.Status.ToString()
|
||||
},
|
||||
expectedVersion);
|
||||
|
||||
await documentStore.SaveAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<(StoredDocument Document, RuntimeEntityDefinition Definition)?> LoadDefinitionDocumentAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await documentStore.LoadAsync(RuntimeEntityPersistenceSchemaProvider.DefinitionsStorageUnit, NormalizeName(name), cancellationToken);
|
||||
return document is null ? null : (document, DeserializeDefinition(document));
|
||||
}
|
||||
|
||||
private async Task<(StoredDocument Document, RuntimeEntityInstance Instance)?> LoadInstanceDocumentAsync(string definitionName, string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await documentStore.LoadAsync(RuntimeEntityPersistenceSchemaProvider.InstancesStorageUnit, CreateInstanceDocumentId(definitionName, id), cancellationToken);
|
||||
return document is null ? null : (document, DeserializeInstance(document));
|
||||
}
|
||||
|
||||
private async Task AppendAuditAsync(string subjectType, string subjectId, string action, string? message, CancellationToken cancellationToken)
|
||||
{
|
||||
var record = new RuntimeEntityAuditRecord
|
||||
{
|
||||
SubjectType = subjectType,
|
||||
SubjectId = subjectId,
|
||||
Action = action,
|
||||
Message = message
|
||||
};
|
||||
|
||||
await documentStore.SaveAsync(new SaveDocumentRequest(
|
||||
RuntimeEntityPersistenceSchemaProvider.AuditStorageUnit,
|
||||
record.Id,
|
||||
JsonSerializer.Serialize(record, _jsonOptions),
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
[nameof(RuntimeEntityAuditRecord.SubjectType)] = record.SubjectType,
|
||||
[nameof(RuntimeEntityAuditRecord.SubjectId)] = record.SubjectId
|
||||
},
|
||||
0), cancellationToken);
|
||||
}
|
||||
|
||||
private Dictionary<string, string?> CreateInstanceIndexValues(RuntimeEntityDefinition definition, RuntimeEntityInstance instance)
|
||||
{
|
||||
var indexValues = new Dictionary<string, string?>
|
||||
{
|
||||
[nameof(RuntimeEntityInstance.DefinitionName)] = instance.DefinitionName
|
||||
};
|
||||
|
||||
for (var slot = 1; slot <= RuntimeEntityPersistenceSchemaProvider.IndexedFieldSlotCount; slot++)
|
||||
{
|
||||
indexValues[$"Index{slot}Name"] = string.Empty;
|
||||
indexValues[$"Index{slot}Value"] = null;
|
||||
}
|
||||
|
||||
for (var index = 0; index < definition.Indexes.Count; index++)
|
||||
{
|
||||
var indexedField = definition.Indexes[index].FieldName;
|
||||
instance.Data.TryGetValue(indexedField, out var value);
|
||||
var slot = index + 1;
|
||||
indexValues[$"Index{slot}Name"] = indexedField;
|
||||
indexValues[$"Index{slot}Value"] = ConvertIndexValue(value);
|
||||
}
|
||||
|
||||
return indexValues;
|
||||
}
|
||||
|
||||
private RuntimeEntityDefinition DeserializeDefinition(StoredDocument document)
|
||||
{
|
||||
return JsonSerializer.Deserialize<RuntimeEntityDefinition>(document.Content, _jsonOptions)
|
||||
?? throw new DocumentStoreValidationException($"Runtime entity definition document '{document.Id}' could not be deserialized.");
|
||||
}
|
||||
|
||||
private RuntimeEntityInstance DeserializeInstance(StoredDocument document)
|
||||
{
|
||||
return JsonSerializer.Deserialize<RuntimeEntityInstance>(document.Content, _jsonOptions)
|
||||
?? throw new DocumentStoreValidationException($"Runtime entity instance document '{document.Id}' could not be deserialized.");
|
||||
}
|
||||
|
||||
private RuntimeEntityAuditRecord DeserializeAudit(StoredDocument document)
|
||||
{
|
||||
return JsonSerializer.Deserialize<RuntimeEntityAuditRecord>(document.Content, _jsonOptions)
|
||||
?? throw new DocumentStoreValidationException($"Runtime entity audit document '{document.Id}' could not be deserialized.");
|
||||
}
|
||||
|
||||
private static string NormalizeName(string name) => name.Trim().ToLowerInvariant();
|
||||
private static string CreateInstanceDocumentId(string definitionName, string id) => $"{NormalizeName(definitionName)}:{id}";
|
||||
|
||||
private static string? ConvertIndexValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => null,
|
||||
DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O", CultureInfo.InvariantCulture),
|
||||
DateTime dateTime => dateTime.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>
|
||||
Experimental SQL Server provider for Elsa Persistence vNext.
|
||||
</Description>
|
||||
<PackageTags>elsa module persistence vnext sqlserver experimental</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext\Elsa.Persistence.VNext.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext.Relational\Elsa.Persistence.VNext.Relational.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait />
|
||||
</Weavers>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using Elsa.Persistence.VNext.Document;
|
||||
using Elsa.Persistence.VNext.Relational.Documents;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Elsa.Persistence.VNext.SqlServer;
|
||||
|
||||
public class SqlServerDocumentStore : RelationalDocumentStore
|
||||
{
|
||||
public SqlServerDocumentStore(SqlConnection connection, PersistenceSchema schema) : base(connection, schema, new SqlServerDocumentStoreDialect())
|
||||
{
|
||||
}
|
||||
|
||||
public SqlServerDocumentStore(SqlConnection connection, DocumentDatabasePlan plan) : base(connection, plan, new SqlServerDocumentStoreDialect())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
using Elsa.Persistence.VNext.Relational.Documents;
|
||||
|
||||
namespace Elsa.Persistence.VNext.SqlServer;
|
||||
|
||||
public class SqlServerDocumentStoreDialect : RelationalDocumentStoreDialect
|
||||
{
|
||||
public override string Parameter(string name)
|
||||
{
|
||||
return $"@{name}";
|
||||
}
|
||||
|
||||
public override string QuoteIdentifier(string identifier)
|
||||
{
|
||||
return $"[{identifier.Replace("]", "]]", StringComparison.Ordinal)}]";
|
||||
}
|
||||
|
||||
public override IReadOnlyList<string> CreateMaterializationLockStatements()
|
||||
{
|
||||
return [
|
||||
"""
|
||||
EXEC sp_getapplock
|
||||
@Resource = N'Elsa.Persistence.VNext.Documents',
|
||||
@LockMode = N'Exclusive',
|
||||
@LockOwner = N'Transaction',
|
||||
@LockTimeout = 60000;
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
public override IReadOnlyList<string> CreateMaterializationStatements()
|
||||
{
|
||||
return [
|
||||
$"""
|
||||
IF OBJECT_ID(N{QuoteStringLiteral("ElsaDocuments")}, N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE {DocumentsTable} (
|
||||
{QuoteIdentifier("StorageUnit")} nvarchar(450) NOT NULL,
|
||||
{QuoteIdentifier("Id")} nvarchar(450) NOT NULL,
|
||||
{QuoteIdentifier("Content")} nvarchar(max) NOT NULL,
|
||||
{QuoteIdentifier("Version")} bigint NOT NULL,
|
||||
{QuoteIdentifier("CreatedAt")} nvarchar(64) NOT NULL,
|
||||
{QuoteIdentifier("UpdatedAt")} nvarchar(64) NOT NULL,
|
||||
CONSTRAINT {QuoteIdentifier("PK_ElsaDocuments")} PRIMARY KEY ({QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("Id")})
|
||||
);
|
||||
END;
|
||||
""",
|
||||
$"""
|
||||
IF OBJECT_ID(N{QuoteStringLiteral("ElsaDocumentIndexValues")}, N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE {IndexValuesTable} (
|
||||
{QuoteIdentifier("StorageUnit")} nvarchar(450) NOT NULL,
|
||||
{QuoteIdentifier("IndexName")} nvarchar(450) NOT NULL,
|
||||
{QuoteIdentifier("FieldName")} nvarchar(450) NOT NULL,
|
||||
{QuoteIdentifier("FieldValue")} nvarchar(450) NULL,
|
||||
{QuoteIdentifier("DocumentId")} nvarchar(450) NOT NULL
|
||||
);
|
||||
END;
|
||||
""",
|
||||
$"""
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ElsaDocumentIndexValues_Lookup' AND object_id = OBJECT_ID(N{QuoteStringLiteral("ElsaDocumentIndexValues")}))
|
||||
CREATE INDEX {QuoteIdentifier("IX_ElsaDocumentIndexValues_Lookup")}
|
||||
ON {IndexValuesTable} ({QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("IndexName")}, {QuoteIdentifier("FieldName")}, {QuoteIdentifier("FieldValue")}, {QuoteIdentifier("DocumentId")});
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
public override string RenderUpsertDocumentSql()
|
||||
{
|
||||
return $"""
|
||||
UPDATE {DocumentsTable}
|
||||
SET {QuoteIdentifier("Content")} = {Parameter("content")},
|
||||
{QuoteIdentifier("Version")} = {Parameter("version")},
|
||||
{QuoteIdentifier("UpdatedAt")} = {Parameter("updatedAt")}
|
||||
WHERE {QuoteIdentifier("StorageUnit")} = {Parameter("storageUnit")} AND {QuoteIdentifier("Id")} = {Parameter("id")};
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
INSERT INTO {DocumentsTable} ({QuoteIdentifier("StorageUnit")}, {QuoteIdentifier("Id")}, {QuoteIdentifier("Content")}, {QuoteIdentifier("Version")}, {QuoteIdentifier("CreatedAt")}, {QuoteIdentifier("UpdatedAt")})
|
||||
VALUES ({Parameter("storageUnit")}, {Parameter("id")}, {Parameter("content")}, {Parameter("version")}, {Parameter("createdAt")}, {Parameter("updatedAt")});
|
||||
END;
|
||||
""";
|
||||
}
|
||||
|
||||
private static string QuoteStringLiteral(string value)
|
||||
{
|
||||
return $"'{value.Replace("'", "''", StringComparison.Ordinal)}'";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
using System.Text;
|
||||
using Elsa.Persistence.VNext.Contracts;
|
||||
using Elsa.Persistence.VNext.Relational;
|
||||
|
||||
namespace Elsa.Persistence.VNext.SqlServer;
|
||||
|
||||
public class SqlServerSchemaSqlRenderer : IPersistenceSchemaRenderer<RelationalSchemaPlan>
|
||||
{
|
||||
public IReadOnlyList<string> Render(RelationalSchemaPlan plan)
|
||||
{
|
||||
var statements = new List<string>();
|
||||
statements.AddRange(plan.Tables.SelectMany(RenderCreateTable));
|
||||
statements.AddRange(plan.Indexes.Select(RenderCreateIndex));
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> RenderCreateTable(RelationalTable table)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(table.Schema))
|
||||
yield return $"IF SCHEMA_ID(N{QuoteStringLiteral(table.Schema)}) IS NULL EXEC(N'CREATE SCHEMA {Quote(table.Schema)}');";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("IF OBJECT_ID(N");
|
||||
builder.Append(QuoteStringLiteral(QualifiedName(table)));
|
||||
builder.AppendLine(", N'U') IS NULL");
|
||||
builder.AppendLine("BEGIN");
|
||||
builder.Append(" CREATE TABLE ");
|
||||
builder.Append(QualifiedName(table));
|
||||
builder.AppendLine(" (");
|
||||
|
||||
var definitions = table.Columns
|
||||
.Select(column => $" {RenderColumn(column)}")
|
||||
.ToList();
|
||||
|
||||
if (table.PrimaryKey is not null)
|
||||
definitions.Add($" CONSTRAINT {Quote(table.PrimaryKey.Name)} PRIMARY KEY ({RenderColumnList(table.PrimaryKey.Columns)})");
|
||||
|
||||
builder.AppendLine(string.Join("," + Environment.NewLine, definitions));
|
||||
builder.AppendLine(" );");
|
||||
builder.Append("END;");
|
||||
yield return builder.ToString();
|
||||
}
|
||||
|
||||
private static string RenderCreateIndex(RelationalIndex index)
|
||||
{
|
||||
var table = new RelationalTable(index.Table, index.Schema, [], null);
|
||||
var unique = index.IsUnique ? "UNIQUE " : "";
|
||||
return $"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N{QuoteStringLiteral(index.Name)} AND object_id = OBJECT_ID(N{QuoteStringLiteral(QualifiedName(table))})) CREATE {unique}INDEX {Quote(index.Name)} ON {QualifiedName(table)} ({RenderColumnList(index.Columns)});";
|
||||
}
|
||||
|
||||
private static string RenderColumn(RelationalColumn column)
|
||||
{
|
||||
var nullability = column.IsNullable ? "NULL" : "NOT NULL";
|
||||
return $"{Quote(column.Name)} {column.StoreType} {nullability}";
|
||||
}
|
||||
|
||||
private static string RenderColumnList(IEnumerable<string> columns)
|
||||
{
|
||||
return string.Join(", ", columns.Select(Quote));
|
||||
}
|
||||
|
||||
private static string QualifiedName(RelationalTable table)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(table.Schema)
|
||||
? Quote(table.Name)
|
||||
: $"{Quote(table.Schema)}.{Quote(table.Name)}";
|
||||
}
|
||||
|
||||
private static string Quote(string identifier)
|
||||
{
|
||||
return $"[{identifier.Replace("]", "]]", StringComparison.Ordinal)}]";
|
||||
}
|
||||
|
||||
private static string QuoteStringLiteral(string value)
|
||||
{
|
||||
return $"'{value.Replace("'", "''", StringComparison.Ordinal)}'";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
using Elsa.Persistence.VNext.Relational;
|
||||
|
||||
namespace Elsa.Persistence.VNext.SqlServer;
|
||||
|
||||
public class SqlServerTypeMapper : IRelationalTypeMapper
|
||||
{
|
||||
public string Map(PersistenceColumn column)
|
||||
{
|
||||
return column.Type switch
|
||||
{
|
||||
PersistenceColumnType.Int32 => "int",
|
||||
PersistenceColumnType.Int64 => "bigint",
|
||||
PersistenceColumnType.Boolean => "bit",
|
||||
PersistenceColumnType.DateTimeOffset => "datetimeoffset",
|
||||
PersistenceColumnType.Text or PersistenceColumnType.Json => "nvarchar(max)",
|
||||
PersistenceColumnType.String => column.Length is { } length ? $"nvarchar({length})" : "nvarchar(max)",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(column), column.Type, "Unsupported persistence column type.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>
|
||||
Experimental SQLite provider for Elsa Persistence vNext.
|
||||
</Description>
|
||||
<PackageTags>elsa module persistence vnext sqlite experimental</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext\Elsa.Persistence.VNext.csproj" />
|
||||
<ProjectReference Include="..\Elsa.Persistence.VNext.Relational\Elsa.Persistence.VNext.Relational.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait />
|
||||
</Weavers>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using Elsa.Persistence.VNext;
|
||||
using Elsa.Persistence.VNext.Physicalization;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Sqlite.Physicalization;
|
||||
|
||||
public partial class SqlitePhysicalizationPlanner : IPhysicalizationPlanner
|
||||
{
|
||||
public PhysicalizationPlan Plan(PersistenceSchema schema, StoragePhysicalizationPolicy policy)
|
||||
{
|
||||
if (policy.Target != PhysicalizationTarget.DedicatedRelationalTable)
|
||||
throw new InvalidOperationException("SQLite physicalization supports dedicated relational tables only.");
|
||||
|
||||
var storageUnit = PhysicalizationPolicyValidator.GetStorageUnit(schema, policy);
|
||||
var tableName = Quote(policy.PhysicalName ?? policy.StorageUnit);
|
||||
var fieldColumns = storageUnit.Fields
|
||||
.Where(field => field.Name != "Id")
|
||||
.Select(field => $" {Quote(field.Name)} TEXT NULL");
|
||||
var columns = string.Join(",\n", fieldColumns);
|
||||
var separator = string.IsNullOrWhiteSpace(columns) ? string.Empty : ",\n";
|
||||
var operations = new List<PhysicalizationOperation>
|
||||
{
|
||||
new(
|
||||
"CreateTable",
|
||||
$"Create dedicated SQLite table {tableName} for {policy.StorageUnit}.",
|
||||
$"""
|
||||
CREATE TABLE IF NOT EXISTS {tableName} (
|
||||
Id TEXT NOT NULL PRIMARY KEY,
|
||||
Content TEXT NOT NULL,
|
||||
Version INTEGER NOT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL{separator}{columns}
|
||||
);
|
||||
""")
|
||||
};
|
||||
|
||||
foreach (var index in policy.Indexes)
|
||||
{
|
||||
var indexName = Quote(index.Name);
|
||||
var indexColumns = string.Join(", ", index.Fields.Select(field => Quote(field)));
|
||||
var unique = index.IsUnique ? "UNIQUE " : string.Empty;
|
||||
operations.Add(new(
|
||||
"CreateIndex",
|
||||
$"Create SQLite index {indexName} on {tableName}.",
|
||||
$"CREATE {unique}INDEX IF NOT EXISTS {indexName} ON {tableName} ({indexColumns});"));
|
||||
}
|
||||
|
||||
return new PhysicalizationPlan("SQLite", policy, operations);
|
||||
}
|
||||
|
||||
private static string Quote(string identifier)
|
||||
{
|
||||
if (!IdentifierRegex().IsMatch(identifier))
|
||||
throw new InvalidOperationException($"SQLite identifier '{identifier}' is not valid for physicalization.");
|
||||
|
||||
return $"\"{identifier}\"";
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.Compiled)]
|
||||
private static partial Regex IdentifierRegex();
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using Elsa.Persistence.VNext.Document;
|
||||
using Elsa.Persistence.VNext.Relational.Documents;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Sqlite;
|
||||
|
||||
public class SqliteDocumentStore : RelationalDocumentStore
|
||||
{
|
||||
public SqliteDocumentStore(SqliteConnection connection, PersistenceSchema schema) : base(connection, schema, new SqliteDocumentStoreDialect())
|
||||
{
|
||||
}
|
||||
|
||||
public SqliteDocumentStore(SqliteConnection connection, DocumentDatabasePlan plan) : base(connection, plan, new SqliteDocumentStoreDialect())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using Elsa.Persistence.VNext.Relational.Documents;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Sqlite;
|
||||
|
||||
public class SqliteDocumentStoreDialect : RelationalDocumentStoreDialect
|
||||
{
|
||||
public override string Parameter(string name)
|
||||
{
|
||||
return $"${name}";
|
||||
}
|
||||
|
||||
public override string QuoteIdentifier(string identifier)
|
||||
{
|
||||
return $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
using System.Text;
|
||||
using Elsa.Persistence.VNext.Contracts;
|
||||
using Elsa.Persistence.VNext.Relational;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Sqlite;
|
||||
|
||||
public class SqliteSchemaSqlRenderer : IPersistenceSchemaRenderer<RelationalSchemaPlan>
|
||||
{
|
||||
public IReadOnlyList<string> Render(RelationalSchemaPlan plan)
|
||||
{
|
||||
var statements = new List<string>();
|
||||
statements.AddRange(plan.Tables.Select(RenderCreateTable));
|
||||
statements.AddRange(plan.Indexes.Select(RenderCreateIndex));
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static string RenderCreateTable(RelationalTable table)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("CREATE TABLE IF NOT EXISTS ");
|
||||
builder.Append(Quote(table.Name));
|
||||
builder.AppendLine(" (");
|
||||
|
||||
var definitions = table.Columns
|
||||
.Select(column => $" {RenderColumn(column)}")
|
||||
.ToList();
|
||||
|
||||
if (table.PrimaryKey is not null)
|
||||
definitions.Add($" CONSTRAINT {Quote(table.PrimaryKey.Name)} PRIMARY KEY ({RenderColumnList(table.PrimaryKey.Columns)})");
|
||||
|
||||
builder.AppendLine(string.Join("," + Environment.NewLine, definitions));
|
||||
builder.Append(");");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string RenderCreateIndex(RelationalIndex index)
|
||||
{
|
||||
var unique = index.IsUnique ? "UNIQUE " : "";
|
||||
return $"CREATE {unique}INDEX IF NOT EXISTS {Quote(index.Name)} ON {Quote(index.Table)} ({RenderColumnList(index.Columns)});";
|
||||
}
|
||||
|
||||
private static string RenderColumn(RelationalColumn column)
|
||||
{
|
||||
var nullability = column.IsNullable ? "" : " NOT NULL";
|
||||
return $"{Quote(column.Name)} {column.StoreType}{nullability}";
|
||||
}
|
||||
|
||||
private static string RenderColumnList(IEnumerable<string> columns)
|
||||
{
|
||||
return string.Join(", ", columns.Select(Quote));
|
||||
}
|
||||
|
||||
private static string Quote(string identifier)
|
||||
{
|
||||
return $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
using Elsa.Persistence.VNext.Contracts;
|
||||
using Elsa.Persistence.VNext.Relational;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Sqlite;
|
||||
|
||||
public class SqliteSchemaVersionRunner : IPersistenceSchemaMigrator<SqliteConnection>
|
||||
{
|
||||
private const string HistoryTable = "ElsaSchemaVersions";
|
||||
private readonly RelationalSchemaPlanner _planner;
|
||||
private readonly SqliteSchemaSqlRenderer _renderer;
|
||||
|
||||
public SqliteSchemaVersionRunner() : this(new RelationalSchemaPlanner(new SqliteTypeMapper()), new SqliteSchemaSqlRenderer())
|
||||
{
|
||||
}
|
||||
|
||||
public SqliteSchemaVersionRunner(RelationalSchemaPlanner planner, SqliteSchemaSqlRenderer renderer)
|
||||
{
|
||||
_planner = planner;
|
||||
_renderer = renderer;
|
||||
}
|
||||
|
||||
public async Task<PersistenceSchemaMigrationResult> MigrateAsync(SqliteConnection connection, PersistenceSchema schema, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (connection.State != System.Data.ConnectionState.Open)
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
await EnsureHistoryTableAsync(connection, transaction, cancellationToken);
|
||||
|
||||
var currentVersion = await GetCurrentVersionAsync(connection, transaction, schema.Name, cancellationToken);
|
||||
if (currentVersion >= schema.Version)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new PersistenceSchemaMigrationResult(schema.Name, currentVersion, schema.Version, false, []);
|
||||
}
|
||||
|
||||
var statements = _renderer.Render(_planner.Plan(schema));
|
||||
foreach (var statement in statements)
|
||||
await ExecuteAsync(connection, transaction, statement, cancellationToken);
|
||||
|
||||
await RecordVersionAsync(connection, transaction, schema, cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new PersistenceSchemaMigrationResult(schema.Name, currentVersion, schema.Version, true, statements);
|
||||
}
|
||||
|
||||
private static Task EnsureHistoryTableAsync(SqliteConnection connection, SqliteTransaction transaction, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = $"""
|
||||
CREATE TABLE IF NOT EXISTS "{HistoryTable}" (
|
||||
"SchemaName" TEXT NOT NULL,
|
||||
"Version" INTEGER NOT NULL,
|
||||
"AppliedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "PK_ElsaSchemaVersions" PRIMARY KEY ("SchemaName", "Version")
|
||||
);
|
||||
""";
|
||||
|
||||
return ExecuteAsync(connection, transaction, sql, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<int> GetCurrentVersionAsync(SqliteConnection connection, SqliteTransaction transaction, string schemaName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = $"""SELECT COALESCE(MAX("Version"), 0) FROM "{HistoryTable}" WHERE "SchemaName" = $schemaName;""";
|
||||
command.Parameters.AddWithValue("$schemaName", schemaName);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static async Task RecordVersionAsync(SqliteConnection connection, SqliteTransaction transaction, PersistenceSchema schema, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = $"""INSERT INTO "{HistoryTable}" ("SchemaName", "Version", "AppliedAt") VALUES ($schemaName, $version, $appliedAt);""";
|
||||
command.Parameters.AddWithValue("$schemaName", schema.Name);
|
||||
command.Parameters.AddWithValue("$version", schema.Version);
|
||||
command.Parameters.AddWithValue("$appliedAt", DateTimeOffset.UtcNow.ToString("O"));
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task ExecuteAsync(SqliteConnection connection, SqliteTransaction transaction, string sql, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
using Elsa.Persistence.VNext.Relational;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Sqlite;
|
||||
|
||||
public class SqliteTypeMapper : IRelationalTypeMapper
|
||||
{
|
||||
public string Map(PersistenceColumn column)
|
||||
{
|
||||
return column.Type switch
|
||||
{
|
||||
PersistenceColumnType.Int32 or PersistenceColumnType.Int64 or PersistenceColumnType.Boolean => "INTEGER",
|
||||
PersistenceColumnType.String or PersistenceColumnType.Text or PersistenceColumnType.Json or PersistenceColumnType.DateTimeOffset => "TEXT",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(column), column.Type, "Unsupported persistence column type.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
namespace Elsa.Persistence.VNext.Builders;
|
||||
|
||||
public class PersistenceSchemaBuilder(string name)
|
||||
{
|
||||
private readonly List<PersistenceTable> _tables = [];
|
||||
private readonly List<PersistenceStorageUnit> _storageUnits = [];
|
||||
private int _version = 1;
|
||||
|
||||
public PersistenceSchemaBuilder Version(int version)
|
||||
{
|
||||
if (version < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(version), version, "Schema version must be greater than zero.");
|
||||
|
||||
_version = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceSchemaBuilder Table(string name, Action<PersistenceTableBuilder> configure, string? schema = null)
|
||||
{
|
||||
var builder = new PersistenceTableBuilder(name, schema);
|
||||
configure(builder);
|
||||
_tables.Add(builder.Build());
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceSchemaBuilder StorageUnit(string name, Action<PersistenceStorageUnitBuilder> configure, string? @namespace = null)
|
||||
{
|
||||
var builder = new PersistenceStorageUnitBuilder(name, @namespace);
|
||||
configure(builder);
|
||||
_storageUnits.Add(builder.Build());
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceSchema Build()
|
||||
{
|
||||
return new PersistenceSchema(Name: name, Version: _version, Tables: _tables.ToList(), StorageUnits: _storageUnits.ToList());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
namespace Elsa.Persistence.VNext.Builders;
|
||||
|
||||
public class PersistenceStorageUnitBuilder(string name, string? @namespace)
|
||||
{
|
||||
private readonly List<PersistenceField> _fields = [];
|
||||
private readonly List<PersistenceIndex> _indexes = [];
|
||||
private PersistencePrimaryKey? _key;
|
||||
|
||||
public PersistenceStorageUnitBuilder Field(string name, PersistenceColumnType type, bool nullable = true, int? length = null)
|
||||
{
|
||||
_fields.Add(new PersistenceField(name, type, nullable, length));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceStorageUnitBuilder RequiredField(string name, PersistenceColumnType type, int? length = null)
|
||||
{
|
||||
return Field(name, type, false, length);
|
||||
}
|
||||
|
||||
public PersistenceStorageUnitBuilder Key(string name, params string[] fields)
|
||||
{
|
||||
_key = new PersistencePrimaryKey(name, fields.ToList());
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceStorageUnitBuilder Index(string name, string field, bool unique = false)
|
||||
{
|
||||
return Index(name, [field], unique);
|
||||
}
|
||||
|
||||
public PersistenceStorageUnitBuilder Index(string name, IReadOnlyList<string> fields, bool unique = false)
|
||||
{
|
||||
_indexes.Add(new PersistenceIndex(name, fields.ToList(), unique));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceStorageUnit Build()
|
||||
{
|
||||
return new PersistenceStorageUnit(Name: name, Namespace: @namespace, Fields: _fields.ToList(), Key: _key, Indexes: _indexes.ToList());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
namespace Elsa.Persistence.VNext.Builders;
|
||||
|
||||
public class PersistenceTableBuilder(string name, string? schema)
|
||||
{
|
||||
private readonly List<PersistenceColumn> _columns = [];
|
||||
private readonly List<PersistenceIndex> _indexes = [];
|
||||
private PersistencePrimaryKey? _primaryKey;
|
||||
|
||||
public PersistenceTableBuilder Column(string name, PersistenceColumnType type, bool nullable = true, int? length = null)
|
||||
{
|
||||
_columns.Add(new PersistenceColumn(name, type, nullable, length));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceTableBuilder RequiredColumn(string name, PersistenceColumnType type, int? length = null)
|
||||
{
|
||||
return Column(name, type, false, length);
|
||||
}
|
||||
|
||||
public PersistenceTableBuilder PrimaryKey(string name, params string[] columns)
|
||||
{
|
||||
_primaryKey = new PersistencePrimaryKey(name, columns.ToList());
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceTableBuilder Index(string name, string column, bool unique = false)
|
||||
{
|
||||
return Index(name, [column], unique);
|
||||
}
|
||||
|
||||
public PersistenceTableBuilder Index(string name, IReadOnlyList<string> columns, bool unique = false)
|
||||
{
|
||||
_indexes.Add(new PersistenceIndex(name, columns.ToList(), unique));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersistenceTable Build()
|
||||
{
|
||||
return new PersistenceTable(Name: name, Schema: schema, Columns: _columns.ToList(), PrimaryKey: _primaryKey, Indexes: _indexes.ToList());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext.Contracts;
|
||||
|
||||
public interface IPersistenceSchemaMigrator<in TContext>
|
||||
{
|
||||
Task<PersistenceSchemaMigrationResult> MigrateAsync(TContext context, PersistenceSchema schema, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext.Contracts;
|
||||
|
||||
public interface IPersistenceSchemaPlanner<out TPlan>
|
||||
{
|
||||
TPlan Plan(PersistenceSchema schema);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext.Contracts;
|
||||
|
||||
public interface IPersistenceSchemaProvider
|
||||
{
|
||||
PersistenceSchema DescribeSchema();
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext.Contracts;
|
||||
|
||||
public interface IPersistenceSchemaRenderer<in TPlan>
|
||||
{
|
||||
IReadOnlyList<string> Render(TPlan plan);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public record DocumentCollection(
|
||||
string Name,
|
||||
string? Namespace,
|
||||
IReadOnlyList<DocumentField> Fields,
|
||||
IReadOnlyList<string> KeyFields,
|
||||
IReadOnlyList<DocumentIndex> Indexes);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public record DocumentDatabasePlan(
|
||||
IReadOnlyList<DocumentCollection> Collections);
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using Elsa.Persistence.VNext.Contracts;
|
||||
|
||||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public class DocumentDatabasePlanner : IPersistenceSchemaPlanner<DocumentDatabasePlan>
|
||||
{
|
||||
public DocumentDatabasePlan Plan(PersistenceSchema schema)
|
||||
{
|
||||
var collections = schema.StorageUnits.Select(PlanCollection).ToList();
|
||||
return new DocumentDatabasePlan(collections);
|
||||
}
|
||||
|
||||
private static DocumentCollection PlanCollection(PersistenceStorageUnit storageUnit)
|
||||
{
|
||||
var fields = storageUnit.Fields
|
||||
.Select(field => new DocumentField(field.Name, field.Type, field.IsNullable))
|
||||
.ToList();
|
||||
var indexes = storageUnit.Indexes
|
||||
.Select(index => new DocumentIndex(index.Name, storageUnit.Name, storageUnit.Namespace, index.Columns, index.IsUnique))
|
||||
.ToList();
|
||||
var keyFields = storageUnit.Key?.Columns ?? [];
|
||||
|
||||
return new DocumentCollection(storageUnit.Name, storageUnit.Namespace, fields, keyFields, indexes);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public record DocumentField(
|
||||
string Name,
|
||||
PersistenceColumnType Type,
|
||||
bool IsNullable);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public record DocumentIndex(
|
||||
string Name,
|
||||
string Collection,
|
||||
string? Namespace,
|
||||
IReadOnlyList<string> Fields,
|
||||
bool IsUnique);
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public static class DocumentIndexMatcher
|
||||
{
|
||||
public static DocumentIndex FindMatchingIndex(DocumentCollection collection, DocumentQuery query)
|
||||
{
|
||||
var filterFields = query.Filters.Keys.Order(StringComparer.Ordinal).ToArray();
|
||||
var index = collection.Indexes.SingleOrDefault(x => x.Fields.Order(StringComparer.Ordinal).SequenceEqual(filterFields, StringComparer.Ordinal));
|
||||
|
||||
if (index is null)
|
||||
throw new DocumentQueryNotIndexedException(query.StorageUnit, filterFields);
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public record DocumentQuery(
|
||||
string StorageUnit,
|
||||
IReadOnlyDictionary<string, string?> Filters);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public class DocumentQueryNotIndexedException(string storageUnit, IReadOnlyCollection<string> fields) : InvalidOperationException(
|
||||
$"No declared index exists for storage unit '{storageUnit}' and fields '{string.Join(", ", fields.Order())}'.")
|
||||
{
|
||||
public string StorageUnit { get; } = storageUnit;
|
||||
public IReadOnlyCollection<string> Fields { get; } = fields;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public class DocumentStoreConcurrencyException(string storageUnit, string documentId, long? expectedVersion, long? actualVersion) : InvalidOperationException(
|
||||
$"Document '{documentId}' in storage unit '{storageUnit}' expected version '{expectedVersion?.ToString() ?? "<none>"}' but actual version was '{actualVersion?.ToString() ?? "<none>"}'.")
|
||||
{
|
||||
public string StorageUnit { get; } = storageUnit;
|
||||
public string DocumentId { get; } = documentId;
|
||||
public long? ExpectedVersion { get; } = expectedVersion;
|
||||
public long? ActualVersion { get; } = actualVersion;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public class DocumentStoreValidationException(string message) : InvalidOperationException(message);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public interface IDocumentStore
|
||||
{
|
||||
Task MaterializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<StoredDocument> SaveAsync(SaveDocumentRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<StoredDocument?> LoadAsync(string storageUnit, string id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(string storageUnit, string id, long? expectedVersion = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<StoredDocument>> QueryAsync(DocumentQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public record SaveDocumentRequest(
|
||||
string StorageUnit,
|
||||
string Id,
|
||||
string Content,
|
||||
IReadOnlyDictionary<string, string?> IndexValues,
|
||||
long? ExpectedVersion = null);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace Elsa.Persistence.VNext.Document;
|
||||
|
||||
public record StoredDocument(
|
||||
string StorageUnit,
|
||||
string Id,
|
||||
string Content,
|
||||
long Version,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<Description>
|
||||
Experimental provider-neutral persistence descriptors and relational planning for Elsa modules.
|
||||
</Description>
|
||||
<PackageTags>elsa module persistence vnext experimental</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
3
src/modules/Elsa.Persistence.VNext/FodyWeavers.xml
Normal file
3
src/modules/Elsa.Persistence.VNext/FodyWeavers.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<ConfigureAwait />
|
||||
</Weavers>
|
||||
1
src/modules/Elsa.Persistence.VNext/GlobalUsings.cs
Normal file
1
src/modules/Elsa.Persistence.VNext/GlobalUsings.cs
Normal file
|
|
@ -0,0 +1 @@
|
|||
global using Elsa.Persistence.VNext;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace Elsa.Persistence.VNext;
|
||||
|
||||
public record PersistenceColumn(
|
||||
string Name,
|
||||
PersistenceColumnType Type,
|
||||
bool IsNullable = true,
|
||||
int? Length = null);
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace Elsa.Persistence.VNext;
|
||||
|
||||
public enum PersistenceColumnType
|
||||
{
|
||||
String,
|
||||
Text,
|
||||
Json,
|
||||
Int32,
|
||||
Int64,
|
||||
Boolean,
|
||||
DateTimeOffset
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace Elsa.Persistence.VNext;
|
||||
|
||||
public record PersistenceField(
|
||||
string Name,
|
||||
PersistenceColumnType Type,
|
||||
bool IsNullable = true,
|
||||
int? Length = null);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Elsa.Persistence.VNext;
|
||||
|
||||
public record PersistenceIndex(
|
||||
string Name,
|
||||
IReadOnlyList<string> Columns,
|
||||
bool IsUnique = false);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
namespace Elsa.Persistence.VNext;
|
||||
|
||||
public record PersistencePrimaryKey(
|
||||
string Name,
|
||||
IReadOnlyList<string> Columns);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace Elsa.Persistence.VNext;
|
||||
|
||||
public record PersistenceSchema(
|
||||
string Name,
|
||||
int Version,
|
||||
IReadOnlyList<PersistenceTable> Tables,
|
||||
IReadOnlyList<PersistenceStorageUnit> StorageUnits);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace Elsa.Persistence.VNext;
|
||||
|
||||
public record PersistenceSchemaMigrationResult(
|
||||
string SchemaName,
|
||||
int FromVersion,
|
||||
int ToVersion,
|
||||
bool Applied,
|
||||
IReadOnlyList<string> Statements);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue