From 2e4dcd45726066de6cdc79a182263b87e464af5d Mon Sep 17 00:00:00 2001 From: Bryan Watts Date: Thu, 19 Dec 2019 17:48:03 -0500 Subject: [PATCH 01/30] Fix hanging requests after a topic stops - Write to the client stream whether or not the checkpoint write succeeds - Do not ignore events routed to stopped flows - Avoid a null reference when an existing flow fails to load --- src/Totem.Timeline.EventStore/TimelineDb.cs | 13 ++++++++++--- src/Totem.Timeline/Runtime/FlowHost.cs | 10 ---------- src/Totem.Timeline/Runtime/FlowScope.cs | 16 +++++++++++++--- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/Totem.Timeline.EventStore/TimelineDb.cs b/src/Totem.Timeline.EventStore/TimelineDb.cs index 82485020..7c8e866e 100644 --- a/src/Totem.Timeline.EventStore/TimelineDb.cs +++ b/src/Totem.Timeline.EventStore/TimelineDb.cs @@ -59,7 +59,16 @@ public Task WriteScheduledEvent(TimelinePoint cause) public async Task WriteCheckpoint(Flow flow, TimelinePoint point) { - var result = await _context.AppendToCheckpoint(flow); + WriteResult result; + + try + { + result = await _context.AppendToCheckpoint(flow); + } + finally + { + await TryWriteToClient(flow, point); + } if(flow.Context.IsNew) { @@ -70,8 +79,6 @@ public async Task WriteCheckpoint(Flow flow, TimelinePoint point) { await TryWriteDoneMetadata(flow, result.NextExpectedVersion); } - - await TryWriteToClient(flow, point); } async Task TryWriteInitialMetadata(Flow flow) diff --git a/src/Totem.Timeline/Runtime/FlowHost.cs b/src/Totem.Timeline/Runtime/FlowHost.cs index e01b228a..106ae218 100644 --- a/src/Totem.Timeline/Runtime/FlowHost.cs +++ b/src/Totem.Timeline/Runtime/FlowHost.cs @@ -12,7 +12,6 @@ internal class FlowHost : Connection { readonly Dictionary _flowsByKey = new Dictionary(); readonly HashSet _ignored = new HashSet(); - readonly HashSet _stopped = new HashSet(); readonly ITimelineDb _db; readonly IServiceProvider _services; @@ -71,8 +70,6 @@ async Task ConnectFlow(IFlowScope flow) catch(Exception error) { Log.Error(error, "Failed to connect flow {Flow}; treating as stopped", flow.Key); - - _stopped.Add(flow.Key); } } @@ -84,8 +81,6 @@ void RemoveWhenDone(IFlowScope flow) => if(task.Status == TaskStatus.Faulted) { Log.Error(task.Exception, "[timeline] Flow lifetime ended with an error"); - - _stopped.Add(flow.Key); } else { @@ -120,11 +115,6 @@ bool Ignore(TimelinePoint point, FlowKey route) async Task Enqueue(TimelinePoint point, FlowKey route) { - if(_stopped.Contains(route)) - { - return; - } - if(!_flowsByKey.TryGetValue(route, out var flow)) { flow = AddFlow(route); diff --git a/src/Totem.Timeline/Runtime/FlowScope.cs b/src/Totem.Timeline/Runtime/FlowScope.cs index e99cbff3..eed1af9b 100644 --- a/src/Totem.Timeline/Runtime/FlowScope.cs +++ b/src/Totem.Timeline/Runtime/FlowScope.cs @@ -163,9 +163,7 @@ void StartIfFirst() { if(Observation.CanBeFirst) { - Flow = (T) Key.Type.New(); - - FlowContext.Bind(Flow, Key); + CreateFlow(); } else { @@ -176,12 +174,24 @@ void StartIfFirst() } } + void CreateFlow() + { + Flow = (T) Key.Type.New(); + + FlowContext.Bind(Flow, Key); + } + protected abstract Task ObservePoint(); async Task Stop(Exception error) { try { + if(Flow == null) + { + CreateFlow(); + } + Flow.Context.SetError(Point.Position, error.ToString()); await WriteCheckpoint(); From a1da899848a8c6296f207166e556f6e21a606f8c Mon Sep 17 00:00:00 2001 From: Bryan Watts Date: Thu, 19 Dec 2019 17:49:22 -0500 Subject: [PATCH 02/30] Add ThenCreated overloads to Totem.Timeline.Mvc.When --- src/Totem.Timeline.Mvc/When.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Totem.Timeline.Mvc/When.cs b/src/Totem.Timeline.Mvc/When.cs index 9bd8c1db..6c8d40f6 100644 --- a/src/Totem.Timeline.Mvc/When.cs +++ b/src/Totem.Timeline.Mvc/When.cs @@ -19,6 +19,18 @@ public static CommandWhen Then(int statusCode) => public static CommandWhen ThenOk => Then(e => new OkResult()); + public static CommandWhen ThenCreated(Func getLocation, Func getValue) => + Then(e => new CreatedResult(getLocation(e), getValue(e))); + + public static CommandWhen ThenCreated(Func getLocation) => + Then(e => new CreatedResult(getLocation(e), null)); + + public static CommandWhen ThenCreated(Func getLocation, Func getValue) => + Then(e => new CreatedResult(getLocation(e), getValue(e))); + + public static CommandWhen ThenCreated(Func getLocation) => + Then(e => new CreatedResult(getLocation(e), null)); + public static CommandWhen ThenBadRequest => Then(e => new BadRequestResult()); From 8d56094e0c734cb0206113c5dbe433c785abbc89 Mon Sep 17 00:00:00 2001 From: Bryan Watts Date: Fri, 24 Jan 2020 17:14:42 -0500 Subject: [PATCH 03/30] Bind the checkpoint position when clients read queries --- .../Client/ClientDb.cs | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/Totem.Timeline.EventStore/Client/ClientDb.cs b/src/Totem.Timeline.EventStore/Client/ClientDb.cs index 051a0eca..e8eafbb1 100644 --- a/src/Totem.Timeline.EventStore/Client/ClientDb.cs +++ b/src/Totem.Timeline.EventStore/Client/ClientDb.cs @@ -69,14 +69,8 @@ public async Task WriteEvent(Event e) return new TimelinePosition(result.NextExpectedVersion); } - public async Task ReadQuery(FlowKey key) - { - var query = await ReadQueryCheckpoint(key, () => GetDefaultContent(key), e => GetCheckpointContent(key, e)); - - FlowContext.Bind(query, key); - - return query; - } + public Task ReadQuery(FlowKey key) => + ReadQueryCheckpoint(key, () => GetDefaultContent(key), e => GetCheckpointContent(key, e)); public Task ReadQueryContent(QueryETag etag) => ReadQueryCheckpoint(etag.Key, () => GetDefaultContent(etag), e => GetCheckpointContent(etag, e)); @@ -99,8 +93,14 @@ async Task ReadQueryCheckpoint(FlowKey key, Func getD } } - Query GetDefaultContent(FlowKey key) => - (Query) key.Type.New(); + Query GetDefaultContent(FlowKey key) + { + var query = (Query) key.Type.New(); + + FlowContext.Bind(query, key); + + return query; + } Query GetCheckpointContent(FlowKey key, ResolvedEvent e) { @@ -111,7 +111,11 @@ Query GetCheckpointContent(FlowKey key, ResolvedEvent e) throw new Exception($"Query is stopped at {metadata.ErrorPosition} with the following error: {metadata.ErrorMessage}"); } - return (Query) _context.Json.FromJsonUtf8(e.Event.Data, key.Type.DeclaredType); + var query = (Query) _context.Json.FromJsonUtf8(e.Event.Data, key.Type.DeclaredType); + + FlowContext.Bind(query, key, metadata.Position, metadata.ErrorPosition); + + return query; } QueryContent GetDefaultContent(QueryETag etag) From d3d3007e83bab03f088879302febd45c04a650d4 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 13:06:53 -0500 Subject: [PATCH 04/30] Modernize AppVeyor and pin SDK --- appveyor.yml | 14 +++++--------- global.json | 5 +++++ 2 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 global.json diff --git a/appveyor.yml b/appveyor.yml index d76d36d5..bf863643 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,6 @@ version: 1.0.0-beta.{build} -image: Visual Studio 2017 +image: Visual Studio 2022 configuration: Release platform: Any CPU @@ -37,14 +37,10 @@ environment: ASPNETCORE_ENVIRONMENT: Production before_build: - - nuget restore -verbosity quiet + - dotnet restore -build: - project: Totem.sln - publish_nuget: false - publish_nuget_symbols: false - include_nuget_references: false - verbosity: minimal +build_script: + - dotnet build .\Totem.sln -c Release test: off @@ -60,4 +56,4 @@ deploy: artifact: /.*\.nupkg/ cache: - - '%LocalAppData%\NuGet\v3-cache' \ No newline at end of file + - '%LocalAppData%\NuGet\v3-cache' diff --git a/global.json b/global.json new file mode 100644 index 00000000..c2af57a3 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "10.0.102" + } +} From e1beaf99a0654b52dfa793ee5cd3b76286f810c8 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 13:08:26 -0500 Subject: [PATCH 05/30] Retarget app projects to net10.0 --- src/Totem.App.Service/Totem.App.Service.csproj | 2 +- src/Totem.App.Web/Totem.App.Web.csproj | 7 ++----- .../Totem.Timeline.IntegrationTests.csproj | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Totem.App.Service/Totem.App.Service.csproj b/src/Totem.App.Service/Totem.App.Service.csproj index d09e3322..d9032b87 100644 --- a/src/Totem.App.Service/Totem.App.Service.csproj +++ b/src/Totem.App.Service/Totem.App.Service.csproj @@ -1,7 +1,7 @@ - netcoreapp2.2 + net10.0 7.1 Totem.App.Service Totem diff --git a/src/Totem.App.Web/Totem.App.Web.csproj b/src/Totem.App.Web/Totem.App.Web.csproj index 0ead0e14..7d1833a4 100644 --- a/src/Totem.App.Web/Totem.App.Web.csproj +++ b/src/Totem.App.Web/Totem.App.Web.csproj @@ -1,7 +1,7 @@ - netcoreapp2.2 + net10.0 7.1 Totem.App.Web Totem @@ -23,10 +23,7 @@ - - - - + diff --git a/tests/Totem.Timeline.IntegrationTests/Totem.Timeline.IntegrationTests.csproj b/tests/Totem.Timeline.IntegrationTests/Totem.Timeline.IntegrationTests.csproj index 7b865428..475a723a 100644 --- a/tests/Totem.Timeline.IntegrationTests/Totem.Timeline.IntegrationTests.csproj +++ b/tests/Totem.Timeline.IntegrationTests/Totem.Timeline.IntegrationTests.csproj @@ -1,6 +1,6 @@ - netcoreapp2.2 + net10.0 89a65faa-2680-45c8-92be-9b40ef83234f @@ -25,4 +25,4 @@ Always - \ No newline at end of file + From 31f5a2e08d3dd24727d6ef6d8d1dc50b309e2309 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 13:08:33 -0500 Subject: [PATCH 06/30] Retarget ASP.NET libraries to net10.0 --- src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj | 5 ++--- src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj | 7 ++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj b/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj index 83b7bce8..98f1a7f8 100644 --- a/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj +++ b/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + net10.0 7.1 Totem.Timeline.Mvc Totem @@ -23,8 +23,7 @@ - - + diff --git a/src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj b/src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj index caea9030..e70bd6e0 100644 --- a/src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj +++ b/src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + net10.0 7.1 Totem.Timeline.SignalR Totem @@ -23,10 +23,7 @@ - - - - + From 670c72ec895a0c226417a8bdfc472f96cba44375 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 13:16:47 -0500 Subject: [PATCH 07/30] Update MVC and SignalR for net10 --- src/Totem.App.Web/Configure.cs | 8 ++++---- src/Totem.App.Web/ConfigureWebApp.cs | 12 ++++++------ .../Hosting/WebRuntimeOptionsSetup.cs | 9 +++++---- src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj | 1 + .../Hosting/TimelineHubRouteBuilderExtensions.cs | 9 +++++---- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/Totem.App.Web/Configure.cs b/src/Totem.App.Web/Configure.cs index f516d8bc..bf69d611 100644 --- a/src/Totem.App.Web/Configure.cs +++ b/src/Totem.App.Web/Configure.cs @@ -45,7 +45,7 @@ public static ConfigureWebApp BeforeSignalR(Action configure) => new ConfigureWebApp().BeforeMvcRoutes(configure); - public static ConfigureWebApp BeforeSignalRRoutes(Action configure) => + public static ConfigureWebApp BeforeSignalRRoutes(Action configure) => new ConfigureWebApp().BeforeSignalRRoutes(configure); public static ConfigureWebApp BeforeMvcApp(Action configure) => @@ -81,7 +81,7 @@ public static ConfigureWebApp AfterSignalR(Action configure) => new ConfigureWebApp().AfterMvcRoutes(configure); - public static ConfigureWebApp AfterSignalRRoutes(Action configure) => + public static ConfigureWebApp AfterSignalRRoutes(Action configure) => new ConfigureWebApp().AfterSignalRRoutes(configure); public static ConfigureWebApp AfterMvcApp(Action configure) => @@ -117,7 +117,7 @@ public static ConfigureWebApp ReplaceSignalR(Action configure) => new ConfigureWebApp().ReplaceMvcRoutes(configure); - public static ConfigureWebApp ReplaceSignalRRoutes(Action configure) => + public static ConfigureWebApp ReplaceSignalRRoutes(Action configure) => new ConfigureWebApp().ReplaceSignalRRoutes(configure); public static ConfigureWebApp ReplaceMvcApp(Action configure) => @@ -184,4 +184,4 @@ public static ConfigureWebApp ReplaceMvc(Action configure) = public static ConfigureWebApp ReplaceSignalR(Action configure) => new ConfigureWebApp().ReplaceSignalR(configure); } -} \ No newline at end of file +} diff --git a/src/Totem.App.Web/ConfigureWebApp.cs b/src/Totem.App.Web/ConfigureWebApp.cs index cf218724..3bad96b9 100644 --- a/src/Totem.App.Web/ConfigureWebApp.cs +++ b/src/Totem.App.Web/ConfigureWebApp.cs @@ -33,7 +33,7 @@ class WebStep : ConfigureStep {} readonly WebStep _mvc = new WebStep(); readonly WebStep _signalR = new WebStep(); readonly ConfigureStep _mvcRoutes = new ConfigureStep(); - readonly ConfigureStep _signalRRoutes = new ConfigureStep(); + readonly ConfigureStep _signalRRoutes = new ConfigureStep(); readonly ConfigureStep _mvcApp = new ConfigureStep(); readonly ConfigureStep _signalRApp = new ConfigureStep(); string _webRoot; @@ -84,7 +84,7 @@ public ConfigureWebApp BeforeSignalR(Action configure) => _mvcRoutes.Before(this, configure); - public ConfigureWebApp BeforeSignalRRoutes(Action configure) => + public ConfigureWebApp BeforeSignalRRoutes(Action configure) => _signalRRoutes.Before(this, configure); public ConfigureWebApp BeforeMvcApp(Action configure) => @@ -120,7 +120,7 @@ public ConfigureWebApp AfterSignalR(Action configure) => _mvcRoutes.After(this, configure); - public ConfigureWebApp AfterSignalRRoutes(Action configure) => + public ConfigureWebApp AfterSignalRRoutes(Action configure) => _signalRRoutes.After(this, configure); public ConfigureWebApp AfterMvcApp(Action configure) => @@ -156,7 +156,7 @@ public ConfigureWebApp ReplaceSignalR(Action configure) => _mvcRoutes.Replace(this, configure); - public ConfigureWebApp ReplaceSignalRRoutes(Action configure) => + public ConfigureWebApp ReplaceSignalRRoutes(Action configure) => _signalRRoutes.Replace(this, configure); public ConfigureWebApp ReplaceMvcApp(Action configure) => @@ -252,7 +252,7 @@ public void ApplyApp(IWebHostBuilder host) => _mvcRoutes.Apply(routes))); _signalRApp.Apply(app, () => - app.UseSignalR(routes => + app.UseEndpoints(routes => _signalRRoutes.Apply(routes, () => routes.MapQueryHub()))); })); @@ -318,4 +318,4 @@ public void ApplySerilog(IWebHostBuilder host) })); } } -} \ No newline at end of file +} diff --git a/src/Totem.Timeline.Mvc/Hosting/WebRuntimeOptionsSetup.cs b/src/Totem.Timeline.Mvc/Hosting/WebRuntimeOptionsSetup.cs index 0171e871..bca0bd2f 100644 --- a/src/Totem.Timeline.Mvc/Hosting/WebRuntimeOptionsSetup.cs +++ b/src/Totem.Timeline.Mvc/Hosting/WebRuntimeOptionsSetup.cs @@ -1,13 +1,14 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.NewtonsoftJson; using Microsoft.Extensions.Options; using Totem.Runtime.Hosting; namespace Totem.Timeline.Mvc.Hosting { /// - /// Configures JSON serialization via + /// Configures JSON serialization via /// - public class WebRuntimeOptionsSetup : IPostConfigureOptions + public class WebRuntimeOptionsSetup : IPostConfigureOptions { readonly IOptions _jsonFormatOptions; @@ -16,7 +17,7 @@ public WebRuntimeOptionsSetup(IOptions jsonFormatOptions) _jsonFormatOptions = jsonFormatOptions; } - public void PostConfigure(string name, MvcJsonOptions options) + public void PostConfigure(string name, MvcNewtonsoftJsonOptions options) { var source = _jsonFormatOptions.Value.SerializerSettings; var target = options.SerializerSettings; @@ -50,4 +51,4 @@ public void PostConfigure(string name, MvcJsonOptions options) target.TypeNameAssemblyFormatHandling = source.TypeNameAssemblyFormatHandling; } } -} \ No newline at end of file +} diff --git a/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj b/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj index 98f1a7f8..83afb1cf 100644 --- a/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj +++ b/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Totem.Timeline.SignalR/Hosting/TimelineHubRouteBuilderExtensions.cs b/src/Totem.Timeline.SignalR/Hosting/TimelineHubRouteBuilderExtensions.cs index ca35ca29..b3973c04 100644 --- a/src/Totem.Timeline.SignalR/Hosting/TimelineHubRouteBuilderExtensions.cs +++ b/src/Totem.Timeline.SignalR/Hosting/TimelineHubRouteBuilderExtensions.cs @@ -1,20 +1,21 @@ using System; using System.ComponentModel; using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.SignalR; namespace Totem.Timeline.SignalR.Hosting { /// - /// Extends to declare timeline hubs + /// Extends to declare timeline hubs /// [EditorBrowsable(EditorBrowsableState.Never)] public static class TimelineHubRouteBuilderExtensions { - public static void MapQueryHub(this HubRouteBuilder routes, Action configure, string path = "/hubs/query") => + public static void MapQueryHub(this IEndpointRouteBuilder routes, Action configure, string path = "/hubs/query") => routes.MapHub(path, configure); - public static void MapQueryHub(this HubRouteBuilder routes, string path = "/hubs/query") => + public static void MapQueryHub(this IEndpointRouteBuilder routes, string path = "/hubs/query") => routes.MapHub(path); } -} \ No newline at end of file +} From 6c17c51d2a2cd4aa7b0ef235b838446d4b25e638 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 13:17:12 -0500 Subject: [PATCH 08/30] Add endpoint routing extensions --- .../Hosting/TimelineHubRouteBuilderExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Totem.Timeline.SignalR/Hosting/TimelineHubRouteBuilderExtensions.cs b/src/Totem.Timeline.SignalR/Hosting/TimelineHubRouteBuilderExtensions.cs index b3973c04..5b6c0eed 100644 --- a/src/Totem.Timeline.SignalR/Hosting/TimelineHubRouteBuilderExtensions.cs +++ b/src/Totem.Timeline.SignalR/Hosting/TimelineHubRouteBuilderExtensions.cs @@ -1,6 +1,7 @@ using System; using System.ComponentModel; using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.SignalR; From 6dbc00458fb20d214714c5e5dd005740c879cdb6 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 13:22:46 -0500 Subject: [PATCH 09/30] Update hosting to HostBuilder --- src/Totem.App.Web/ConfigureWebApp.cs | 2 +- src/Totem.App.Web/WebApp.cs | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Totem.App.Web/ConfigureWebApp.cs b/src/Totem.App.Web/ConfigureWebApp.cs index 3bad96b9..63bd8c6b 100644 --- a/src/Totem.App.Web/ConfigureWebApp.cs +++ b/src/Totem.App.Web/ConfigureWebApp.cs @@ -238,7 +238,7 @@ public void ApplyApp(IWebHostBuilder host) => host.Configure(app => _app.Apply(app, () => { - var environment = app.ApplicationServices.GetRequiredService(); + var environment = app.ApplicationServices.GetRequiredService(); if(environment.IsDevelopment()) { diff --git a/src/Totem.App.Web/WebApp.cs b/src/Totem.App.Web/WebApp.cs index fd3b1427..39f27e9c 100644 --- a/src/Totem.App.Web/WebApp.cs +++ b/src/Totem.App.Web/WebApp.cs @@ -1,7 +1,7 @@ using System; using System.Threading.Tasks; -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; using Totem.Timeline.Hosting; @@ -14,13 +14,15 @@ public static class WebApp { public static Task Run(ConfigureWebApp configure) where TArea : TimelineArea, new() { - var host = WebHost.CreateDefaultBuilder(); - - configure.ApplyHost(host); - configure.ApplyApp(host); - configure.ApplyAppConfiguration(host); - configure.ApplyServices(host); - configure.ApplySerilog(host); + var host = Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults(webHost => + { + configure.ApplyHost(webHost); + configure.ApplyApp(webHost); + configure.ApplyAppConfiguration(webHost); + configure.ApplyServices(webHost); + configure.ApplySerilog(webHost); + }); return host.Build().RunAsync(); } @@ -34,4 +36,5 @@ public static class WebApp public static Task Run() where TArea : TimelineArea, new() => Run(new ConfigureWebApp()); } -} \ No newline at end of file +} + From beb05afb3a848e29deda7be599ed9134f592fa82 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 13:28:14 -0500 Subject: [PATCH 10/30] Upgrade Newtonsoft.Json --- src/Totem.Runtime/Totem.Runtime.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Totem.Runtime/Totem.Runtime.csproj b/src/Totem.Runtime/Totem.Runtime.csproj index 2d90ab68..b353403f 100644 --- a/src/Totem.Runtime/Totem.Runtime.csproj +++ b/src/Totem.Runtime/Totem.Runtime.csproj @@ -28,11 +28,11 @@ - + - \ No newline at end of file + From 0e3dd75c5981692c5e0667a63b340ac30605d374 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 13:35:33 -0500 Subject: [PATCH 11/30] Replace PackageIconUrl with PackageIcon --- src/Totem.App.Service/Totem.App.Service.csproj | 6 +++++- src/Totem.App.Tests/Totem.App.Tests.csproj | 6 +++++- src/Totem.App.Web/Totem.App.Web.csproj | 6 +++++- src/Totem.Runtime/Totem.Runtime.csproj | 6 +++++- .../Totem.Timeline.EventStore.csproj | 6 +++++- src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj | 6 +++++- src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj | 6 +++++- src/Totem.Timeline/Totem.Timeline.csproj | 6 +++++- src/Totem/Totem.csproj | 6 +++++- 9 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/Totem.App.Service/Totem.App.Service.csproj b/src/Totem.App.Service/Totem.App.Service.csproj index d9032b87..7b40999e 100644 --- a/src/Totem.App.Service/Totem.App.Service.csproj +++ b/src/Totem.App.Service/Totem.App.Service.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;ddd;cqrs;event-sourcing;dotnet;dotnet-core;csharp https://github.com/bwatts/Totem @@ -42,4 +42,8 @@ + + + + diff --git a/src/Totem.App.Tests/Totem.App.Tests.csproj b/src/Totem.App.Tests/Totem.App.Tests.csproj index d350739f..92469ae0 100644 --- a/src/Totem.App.Tests/Totem.App.Tests.csproj +++ b/src/Totem.App.Tests/Totem.App.Tests.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;ddd;cqrs;event-sourcing;dotnet;dotnet-core;csharp https://github.com/bwatts/Totem @@ -33,4 +33,8 @@ + + + + diff --git a/src/Totem.App.Web/Totem.App.Web.csproj b/src/Totem.App.Web/Totem.App.Web.csproj index 7d1833a4..33dd3f82 100644 --- a/src/Totem.App.Web/Totem.App.Web.csproj +++ b/src/Totem.App.Web/Totem.App.Web.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;ddd;cqrs;event-sourcing;dotnet;dotnet-core;csharp;aspnet-core;signalr https://github.com/bwatts/Totem @@ -38,4 +38,8 @@ + + + + diff --git a/src/Totem.Runtime/Totem.Runtime.csproj b/src/Totem.Runtime/Totem.Runtime.csproj index b353403f..acf11065 100644 --- a/src/Totem.Runtime/Totem.Runtime.csproj +++ b/src/Totem.Runtime/Totem.Runtime.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;dotnet;dotnet-core;csharp;utilities https://github.com/bwatts/Totem @@ -35,4 +35,8 @@ + + + + diff --git a/src/Totem.Timeline.EventStore/Totem.Timeline.EventStore.csproj b/src/Totem.Timeline.EventStore/Totem.Timeline.EventStore.csproj index 5770e8a3..1cf48dde 100644 --- a/src/Totem.Timeline.EventStore/Totem.Timeline.EventStore.csproj +++ b/src/Totem.Timeline.EventStore/Totem.Timeline.EventStore.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;ddd;cqrs;event-sourcing;dotnet;dotnet-core;csharp;eventstore https://github.com/bwatts/Totem @@ -38,4 +38,8 @@ + + + + diff --git a/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj b/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj index 83afb1cf..e734350f 100644 --- a/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj +++ b/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;ddd;cqrs;event-sourcing;dotnet;dotnet-core;csharp;aspnet-core;mvc https://github.com/bwatts/Totem @@ -33,4 +33,8 @@ + + + + diff --git a/src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj b/src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj index e70bd6e0..021cb599 100644 --- a/src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj +++ b/src/Totem.Timeline.SignalR/Totem.Timeline.SignalR.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;ddd;cqrs;event-sourcing;dotnet;dotnet-core;csharp;aspnet-core;signalr https://github.com/bwatts/Totem @@ -32,4 +32,8 @@ + + + + diff --git a/src/Totem.Timeline/Totem.Timeline.csproj b/src/Totem.Timeline/Totem.Timeline.csproj index 942682b7..aae5e357 100644 --- a/src/Totem.Timeline/Totem.Timeline.csproj +++ b/src/Totem.Timeline/Totem.Timeline.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;ddd;cqrs;event-sourcing;dotnet;dotnet-core;csharp https://github.com/bwatts/Totem @@ -33,4 +33,8 @@ + + + + diff --git a/src/Totem/Totem.csproj b/src/Totem/Totem.csproj index c347a508..61c81b44 100644 --- a/src/Totem/Totem.csproj +++ b/src/Totem/Totem.csproj @@ -14,7 +14,7 @@ MIT false https://github.com/bwatts/Totem - https://raw.githubusercontent.com/bwatts/totem/master/icon.png + icon.png https://github.com/bwatts/Totem/releases totem;dotnet;dotnet-core;csharp;utilities https://github.com/bwatts/Totem @@ -22,4 +22,8 @@ 1591;NU5105 + + + + From 56b67d7aa1747213de0497a7f13b24d5d1386988 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 14:52:44 -0500 Subject: [PATCH 12/30] Modernize test host + gate integration tests --- src/Totem.App.Service/ConfigureServiceApp.cs | 6 +-- .../ServiceAppCancellation.cs | 4 +- .../Totem.App.Service.csproj | 15 ++++---- src/Totem.App.Tests/Hosting/AppHost.cs | 37 +++++++++++++++---- src/Totem.App.Tests/Hosting/AppTests.cs | 15 ++++++-- .../Hosting/QueryAppLifetime.cs | 4 +- .../Hosting/TopicAppLifetime.cs | 4 +- src/Totem.App.Tests/Totem.App.Tests.csproj | 7 +++- .../Totem.Timeline.IntegrationTests/Cause.cs | 5 +-- .../GivenWhenOrder.cs | 5 +-- .../Hosting/EventStoreFactAttribute.cs | 19 ++++++++++ .../Hosting/EventStoreProcessCommand.cs | 11 +++++- .../Hosting/IntegrationAppLifetime.cs | 4 +- .../IntegrationAppServiceExtensions.cs | 17 +++++++-- .../MultipleWhenEvents.cs | 5 +-- .../Position.cs | 5 +-- .../Queries.cs | 5 +-- .../RoutingToLatentInstance.cs | 5 +-- .../RoutingToMultiInstance.cs | 5 +-- .../ScheduledEvents.cs | 5 +-- .../Totem.Timeline.IntegrationTests.csproj | 20 ++++++---- 21 files changed, 136 insertions(+), 67 deletions(-) create mode 100644 tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreFactAttribute.cs diff --git a/src/Totem.App.Service/ConfigureServiceApp.cs b/src/Totem.App.Service/ConfigureServiceApp.cs index 2d632953..6264e3c8 100644 --- a/src/Totem.App.Service/ConfigureServiceApp.cs +++ b/src/Totem.App.Service/ConfigureServiceApp.cs @@ -187,8 +187,8 @@ public void ApplyAppConfiguration(IHostBuilder host) => timeline.AddEventStore().BindOptionsToConfiguration())); // Allow an external host (such as a Windows Service) to stop the application - services.AddSingleton(p => - new ServiceAppCancellation(p.GetService(), _cancellationToken)); + services.AddSingleton(p => + new ServiceAppCancellation(p.GetService(), _cancellationToken)); })); public void ApplySerilog(IHostBuilder host) @@ -222,4 +222,4 @@ public void ApplySerilog(IHostBuilder host) })); } } -} \ No newline at end of file +} diff --git a/src/Totem.App.Service/ServiceAppCancellation.cs b/src/Totem.App.Service/ServiceAppCancellation.cs index a5cb8eac..c8327e07 100644 --- a/src/Totem.App.Service/ServiceAppCancellation.cs +++ b/src/Totem.App.Service/ServiceAppCancellation.cs @@ -9,7 +9,7 @@ namespace Totem.App.Service /// public sealed class ServiceAppCancellation : IHostedService { - public ServiceAppCancellation(IApplicationLifetime lifetimeService, CancellationToken stopToken) + public ServiceAppCancellation(IHostApplicationLifetime lifetimeService, CancellationToken stopToken) { stopToken.Register(lifetimeService.StopApplication); } @@ -20,4 +20,4 @@ public Task StartAsync(CancellationToken cancellationToken) => public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Totem.App.Service/Totem.App.Service.csproj b/src/Totem.App.Service/Totem.App.Service.csproj index 7b40999e..9e7996f5 100644 --- a/src/Totem.App.Service/Totem.App.Service.csproj +++ b/src/Totem.App.Service/Totem.App.Service.csproj @@ -23,13 +23,14 @@ - - - - - - - + + + + + + + + diff --git a/src/Totem.App.Tests/Hosting/AppHost.cs b/src/Totem.App.Tests/Hosting/AppHost.cs index b2cb2793..b3605937 100644 --- a/src/Totem.App.Tests/Hosting/AppHost.cs +++ b/src/Totem.App.Tests/Hosting/AppHost.cs @@ -1,5 +1,7 @@ +using System; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; +using Xunit.Sdk; using Totem.Runtime; using Totem.Threading; @@ -12,13 +14,25 @@ public abstract class AppHost : Connection { readonly TaskSource _startup = new TaskSource(); readonly TaskSource _shutdown = new TaskSource(); - IApplicationLifetime _lifetimeService; + IHostApplicationLifetime _lifetimeService; - protected override Task Open() + protected override async Task Open() { BuildAndRun(); - return _startup.Task; + try + { + await _startup.Task; + } + catch(AggregateException error) + { + if(error.InnerExceptions.Count == 1 && error.InnerException is SkipException skip) + { + throw skip; + } + + throw; + } } protected override Task Close() @@ -28,12 +42,21 @@ protected override Task Close() return _shutdown.Task; } - void BuildAndRun() => - CreateBuilder().Build().RunAsync().ContinueWith(StopHost); + void BuildAndRun() + { + try + { + CreateBuilder().Build().RunAsync().ContinueWith(StopHost); + } + catch(Xunit.Sdk.SkipException skip) + { + _startup.SetException(skip); + } + } protected abstract IHostBuilder CreateBuilder(); - internal void SetLifetimeService(IApplicationLifetime lifetimeService) + internal void SetLifetimeService(IHostApplicationLifetime lifetimeService) { _lifetimeService = lifetimeService; @@ -51,4 +74,4 @@ void StopHost(Task runTask) _shutdown.TrySetResult(); } } -} \ No newline at end of file +} diff --git a/src/Totem.App.Tests/Hosting/AppTests.cs b/src/Totem.App.Tests/Hosting/AppTests.cs index f32d32b4..05a42c77 100644 --- a/src/Totem.App.Tests/Hosting/AppTests.cs +++ b/src/Totem.App.Tests/Hosting/AppTests.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Totem.Runtime; +using Xunit.Sdk; namespace Totem.App.Tests.Hosting { @@ -39,9 +40,15 @@ async Task StopHost() { if(_host.IsValueCreated) { - var host = await _host.Value; - - await host.Disconnect(); + try + { + var host = await _host.Value; + + await host.Disconnect(); + } + catch(Xunit.Sdk.SkipException) + { + } } } @@ -49,4 +56,4 @@ async Task StopHost() protected IClock Clock => Notion.Traits.Clock.Get(this); } -} \ No newline at end of file +} diff --git a/src/Totem.App.Tests/Hosting/QueryAppLifetime.cs b/src/Totem.App.Tests/Hosting/QueryAppLifetime.cs index a769a7a9..3cf23574 100644 --- a/src/Totem.App.Tests/Hosting/QueryAppLifetime.cs +++ b/src/Totem.App.Tests/Hosting/QueryAppLifetime.cs @@ -9,7 +9,7 @@ namespace Totem.App.Tests.Hosting /// internal sealed class QueryAppLifetime : IHostLifetime { - public QueryAppLifetime(QueryAppHost host, QueryApp app, IApplicationLifetime lifetimeService) + public QueryAppLifetime(QueryAppHost host, QueryApp app, IHostApplicationLifetime lifetimeService) { host.SetApp(app); host.SetLifetimeService(lifetimeService); @@ -21,4 +21,4 @@ public Task WaitForStartAsync(CancellationToken cancellationToken) => public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Totem.App.Tests/Hosting/TopicAppLifetime.cs b/src/Totem.App.Tests/Hosting/TopicAppLifetime.cs index 5e6d84de..0463969d 100644 --- a/src/Totem.App.Tests/Hosting/TopicAppLifetime.cs +++ b/src/Totem.App.Tests/Hosting/TopicAppLifetime.cs @@ -9,7 +9,7 @@ namespace Totem.App.Tests.Hosting /// internal sealed class TopicAppLifetime : IHostLifetime { - public TopicAppLifetime(TopicAppHost host, TopicApp app, IApplicationLifetime lifetimeService) + public TopicAppLifetime(TopicAppHost host, TopicApp app, IHostApplicationLifetime lifetimeService) { host.SetApp(app); host.SetLifetimeService(lifetimeService); @@ -21,4 +21,4 @@ public Task WaitForStartAsync(CancellationToken cancellationToken) => public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Totem.App.Tests/Totem.App.Tests.csproj b/src/Totem.App.Tests/Totem.App.Tests.csproj index 92469ae0..9a13b202 100644 --- a/src/Totem.App.Tests/Totem.App.Tests.csproj +++ b/src/Totem.App.Tests/Totem.App.Tests.csproj @@ -20,11 +20,14 @@ https://github.com/bwatts/Totem A base configuration for testing applications, including an in-memory timeline and support for any test framework 1591;NU5105 + false - - + + + + diff --git a/tests/Totem.Timeline.IntegrationTests/Cause.cs b/tests/Totem.Timeline.IntegrationTests/Cause.cs index 92e87fe4..bd70e377 100644 --- a/tests/Totem.Timeline.IntegrationTests/Cause.cs +++ b/tests/Totem.Timeline.IntegrationTests/Cause.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; -using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -9,7 +8,7 @@ namespace Totem.Timeline.IntegrationTests /// public class Cause : IntegrationTest { - [Fact] + [EventStoreFact] public async Task SetByWhen() { await Append(new StartTest()); @@ -44,4 +43,4 @@ class TestStartedObserved : Event public TimelinePosition Position; } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/GivenWhenOrder.cs b/tests/Totem.Timeline.IntegrationTests/GivenWhenOrder.cs index dbdbe038..0741e75d 100644 --- a/tests/Totem.Timeline.IntegrationTests/GivenWhenOrder.cs +++ b/tests/Totem.Timeline.IntegrationTests/GivenWhenOrder.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; -using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -9,7 +8,7 @@ namespace Totem.Timeline.IntegrationTests /// public class GivenWhenOrder : IntegrationTest { - [Fact] + [EventStoreFact] public async Task GivenBeforeWhenForSameEvent() { await Append(new Increment()); @@ -47,4 +46,4 @@ public Incremented(int value) public readonly int Value; } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreFactAttribute.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreFactAttribute.cs new file mode 100644 index 00000000..cec40eb7 --- /dev/null +++ b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreFactAttribute.cs @@ -0,0 +1,19 @@ +using System; +using Xunit; + +namespace Totem.Timeline.IntegrationTests.Hosting +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + internal sealed class EventStoreFactAttribute : FactAttribute + { + const string SkipMessage = "Integration tests require eventStoreProcess:exeFile to be set to an existing EventStoreDB executable."; + + public EventStoreFactAttribute() + { + if(string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("EventStoreExeFile"))) + { + Skip = SkipMessage; + } + } + } +} diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs index 1acfab3b..576ca42f 100644 --- a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs +++ b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs @@ -29,6 +29,11 @@ internal EventStoreProcessCommand(string exeFile, int tcpPort, int httpPort) internal async Task StartProcess() { + if(string.IsNullOrWhiteSpace(_exeFile)) + { + return Process.GetCurrentProcess(); + } + if(IsFirstCommand()) { KillExistingProcesses(); @@ -61,7 +66,9 @@ void KillExistingProcesses() } IEnumerable GetExistingProcesses() => - Process.GetProcessesByName(Path.GetFileNameWithoutExtension(_exeFile)); + string.IsNullOrWhiteSpace(_exeFile) + ? Array.Empty() + : Process.GetProcessesByName(Path.GetFileNameWithoutExtension(_exeFile)); ProcessStartInfo CreateStartInfo() => new ProcessStartInfo @@ -81,4 +88,4 @@ ProcessStartInfo CreateStartInfo() => .Write(" --ext-http-port=").Write(_httpPort), }; } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppLifetime.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppLifetime.cs index 0ccd4c67..77beaea4 100644 --- a/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppLifetime.cs +++ b/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppLifetime.cs @@ -11,7 +11,7 @@ internal sealed class IntegrationAppLifetime : IHostLifetime { readonly IntegrationApp _app; - internal IntegrationAppLifetime(IntegrationAppHost host, IntegrationApp app, IApplicationLifetime lifetimeService) + internal IntegrationAppLifetime(IntegrationAppHost host, IntegrationApp app, IHostApplicationLifetime lifetimeService) { _app = app; @@ -25,4 +25,4 @@ public Task WaitForStartAsync(CancellationToken cancellationToken) => public Task StopAsync(CancellationToken cancellationToken) => _app.Disconnect(); } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs index 39bfc15f..54b15136 100644 --- a/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs +++ b/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Reflection; using System.Threading; using Microsoft.Extensions.Configuration; @@ -44,7 +45,7 @@ static void AddIntegrationApp(this IServiceCollection services, IntegrationAppHo .AddSingleton(p => new IntegrationAppLifetime( host, p.GetService(), - p.GetService())) + p.GetService())) .Add(host.AppServices); static IEnumerable GetAreaTypes(this IntegrationAppHost host) => @@ -58,7 +59,17 @@ static IServiceCollection AddEventStoreProcess(this IServiceCollection services) var processOptions = p.GetOptions(); var timelineOptions = p.GetOptions(); - Expect.That(processOptions.ExeFile).IsNot("", "The eventStoreProcess:exeFile options is required, generally as a user secret"); + if(string.IsNullOrWhiteSpace(processOptions.ExeFile) || + processOptions.ExeFile == "" || + !File.Exists(processOptions.ExeFile)) + { + processOptions.ExeFile = null; + } + + if(string.IsNullOrWhiteSpace(processOptions.ExeFile)) + { + return new EventStoreProcess(new EventStoreProcessCommand(processOptions.ExeFile, 0, 0), processOptions.ReadyDelay); + } var command = new EventStoreProcessCommand( processOptions.ExeFile, @@ -79,4 +90,4 @@ static IServiceCollection ConfigureEventStorePorts(this IServiceCollection servi }); } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/MultipleWhenEvents.cs b/tests/Totem.Timeline.IntegrationTests/MultipleWhenEvents.cs index 455beef2..6d238e52 100644 --- a/tests/Totem.Timeline.IntegrationTests/MultipleWhenEvents.cs +++ b/tests/Totem.Timeline.IntegrationTests/MultipleWhenEvents.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; -using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -9,7 +8,7 @@ namespace Totem.Timeline.IntegrationTests /// public class MultipleWhenEvents : IntegrationTest { - [Fact] + [EventStoreFact] public async Task WrittenAfterWhen() { await Append(new StartTest()); @@ -47,4 +46,4 @@ class SecondHappened : Event { } class FirstObserved : Event { } class SecondObserved : Event { } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/Position.cs b/tests/Totem.Timeline.IntegrationTests/Position.cs index 0bc332dc..62b1b819 100644 --- a/tests/Totem.Timeline.IntegrationTests/Position.cs +++ b/tests/Totem.Timeline.IntegrationTests/Position.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; -using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -9,7 +8,7 @@ namespace Totem.Timeline.IntegrationTests /// public class Position : IntegrationTest { - [Fact] + [EventStoreFact] public async Task IncreasesFrom0() { var position0 = await Append(new Happened()); @@ -23,4 +22,4 @@ public async Task IncreasesFrom0() class Happened : Event { } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/Queries.cs b/tests/Totem.Timeline.IntegrationTests/Queries.cs index b26a018b..f782d3d0 100644 --- a/tests/Totem.Timeline.IntegrationTests/Queries.cs +++ b/tests/Totem.Timeline.IntegrationTests/Queries.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; -using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -9,7 +8,7 @@ namespace Totem.Timeline.IntegrationTests /// public class Queries : IntegrationTest { - [Fact] + [EventStoreFact] public async Task StateUpdatesAfterGiven() { var initial = await GetQuery(); @@ -60,4 +59,4 @@ public Replaced(string value) public readonly string Value; } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/RoutingToLatentInstance.cs b/tests/Totem.Timeline.IntegrationTests/RoutingToLatentInstance.cs index 9a32b895..279177ea 100644 --- a/tests/Totem.Timeline.IntegrationTests/RoutingToLatentInstance.cs +++ b/tests/Totem.Timeline.IntegrationTests/RoutingToLatentInstance.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; -using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -9,7 +8,7 @@ namespace Totem.Timeline.IntegrationTests /// public class RoutingToLatentInstance : IntegrationTest { - [Fact] + [EventStoreFact] public async Task OnlyCreatesForRouteFirst() { var id = Id.From("A"); @@ -76,4 +75,4 @@ public SecondObserved(Id id) public readonly Id Id; } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/RoutingToMultiInstance.cs b/tests/Totem.Timeline.IntegrationTests/RoutingToMultiInstance.cs index 4e1a6756..8907507e 100644 --- a/tests/Totem.Timeline.IntegrationTests/RoutingToMultiInstance.cs +++ b/tests/Totem.Timeline.IntegrationTests/RoutingToMultiInstance.cs @@ -2,7 +2,6 @@ using System.Linq; using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; -using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -11,7 +10,7 @@ namespace Totem.Timeline.IntegrationTests /// public class RoutingToMultiInstance : IntegrationTest { - [Fact] + [EventStoreFact] public async Task CreatesAllInstances() { var ids = Many.Of(Id.From("A"), Id.From("B"), Id.From("C")); @@ -57,4 +56,4 @@ public InstanceCreated(Id id) public readonly Id Id; } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/ScheduledEvents.cs b/tests/Totem.Timeline.IntegrationTests/ScheduledEvents.cs index d670e72a..9037a02e 100644 --- a/tests/Totem.Timeline.IntegrationTests/ScheduledEvents.cs +++ b/tests/Totem.Timeline.IntegrationTests/ScheduledEvents.cs @@ -1,7 +1,6 @@ using System; using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; -using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -10,7 +9,7 @@ namespace Totem.Timeline.IntegrationTests /// public class ScheduledEvents : IntegrationTest { - [Fact] + [EventStoreFact] public async Task OccurWithin50Ms() { await Append(new StartTimer()); @@ -50,4 +49,4 @@ public TimerMeasured(TimeSpan lag) public readonly TimeSpan Lag; } } -} \ No newline at end of file +} diff --git a/tests/Totem.Timeline.IntegrationTests/Totem.Timeline.IntegrationTests.csproj b/tests/Totem.Timeline.IntegrationTests/Totem.Timeline.IntegrationTests.csproj index 475a723a..a9bbeb3a 100644 --- a/tests/Totem.Timeline.IntegrationTests/Totem.Timeline.IntegrationTests.csproj +++ b/tests/Totem.Timeline.IntegrationTests/Totem.Timeline.IntegrationTests.csproj @@ -2,15 +2,21 @@ net10.0 89a65faa-2680-45c8-92be-9b40ef83234f + true - - - - - - - + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + From e00f918c05fdd549d9cfc9c8018b644201d333bc Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Thu, 5 Feb 2026 15:22:09 -0500 Subject: [PATCH 13/30] Use Environments.Development --- src/Totem.App.Service/ConfigureServiceApp.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Totem.App.Service/ConfigureServiceApp.cs b/src/Totem.App.Service/ConfigureServiceApp.cs index 6264e3c8..6ede5e76 100644 --- a/src/Totem.App.Service/ConfigureServiceApp.cs +++ b/src/Totem.App.Service/ConfigureServiceApp.cs @@ -154,7 +154,7 @@ public void ApplyHostConfiguration(IHostBuilder host) => { var pairs = new Dictionary { - [HostDefaults.EnvironmentKey] = Environment.GetEnvironmentVariable("NETCORE_ENVIRONMENT") ?? EnvironmentName.Development + [HostDefaults.EnvironmentKey] = Environment.GetEnvironmentVariable("NETCORE_ENVIRONMENT") ?? Environments.Development }; hostConfiguration.AddInMemoryCollection(pairs); From 11098a9135f703eafa6e9c96b65d51b84fd11159 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Fri, 6 Feb 2026 09:22:47 -0500 Subject: [PATCH 14/30] Disable endpoint routing and enable newtonsoft for dotnet10 support. --- src/Totem.App.Web/ConfigureWebApp.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Totem.App.Web/ConfigureWebApp.cs b/src/Totem.App.Web/ConfigureWebApp.cs index 63bd8c6b..029ac500 100644 --- a/src/Totem.App.Web/ConfigureWebApp.cs +++ b/src/Totem.App.Web/ConfigureWebApp.cs @@ -246,6 +246,7 @@ public void ApplyApp(IWebHostBuilder host) => } app.UseStaticFiles(); + app.UseRouting(); _mvcApp.Apply(app, () => app.UseMvc(routes => @@ -278,7 +279,8 @@ public void ApplyAppConfiguration(IWebHostBuilder host) => _mvc.Apply(context, services, () => services - .AddMvc() + .AddMvc(options => options.EnableEndpointRouting = false) + .AddNewtonsoftJson() .AddTotemWebRuntime() .AddCommandsAndQueries() .AddEntryAssemblyPart()); From 43453b85556fcb69723e897b2b74f3aaa376fea0 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Fri, 6 Feb 2026 09:32:40 -0500 Subject: [PATCH 15/30] Rollback skip testing in the integration suite. --- src/Totem.App.Tests/Hosting/AppHost.cs | 23 ++----------------- src/Totem.App.Tests/Hosting/AppTests.cs | 12 +++------- .../Totem.Timeline.IntegrationTests/Cause.cs | 3 ++- .../GivenWhenOrder.cs | 3 ++- .../Hosting/EventStoreFactAttribute.cs | 19 --------------- .../Hosting/EventStoreProcessCommand.cs | 9 +------- .../IntegrationAppServiceExtensions.cs | 13 ----------- .../MultipleWhenEvents.cs | 3 ++- .../Position.cs | 3 ++- .../Queries.cs | 3 ++- .../RoutingToLatentInstance.cs | 3 ++- .../RoutingToMultiInstance.cs | 3 ++- .../ScheduledEvents.cs | 3 ++- 13 files changed, 22 insertions(+), 78 deletions(-) delete mode 100644 tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreFactAttribute.cs diff --git a/src/Totem.App.Tests/Hosting/AppHost.cs b/src/Totem.App.Tests/Hosting/AppHost.cs index b3605937..437a962a 100644 --- a/src/Totem.App.Tests/Hosting/AppHost.cs +++ b/src/Totem.App.Tests/Hosting/AppHost.cs @@ -20,19 +20,7 @@ protected override async Task Open() { BuildAndRun(); - try - { - await _startup.Task; - } - catch(AggregateException error) - { - if(error.InnerExceptions.Count == 1 && error.InnerException is SkipException skip) - { - throw skip; - } - - throw; - } + await _startup.Task; } protected override Task Close() @@ -44,14 +32,7 @@ protected override Task Close() void BuildAndRun() { - try - { - CreateBuilder().Build().RunAsync().ContinueWith(StopHost); - } - catch(Xunit.Sdk.SkipException skip) - { - _startup.SetException(skip); - } + CreateBuilder().Build().RunAsync().ContinueWith(StopHost); } protected abstract IHostBuilder CreateBuilder(); diff --git a/src/Totem.App.Tests/Hosting/AppTests.cs b/src/Totem.App.Tests/Hosting/AppTests.cs index 05a42c77..4505c1f1 100644 --- a/src/Totem.App.Tests/Hosting/AppTests.cs +++ b/src/Totem.App.Tests/Hosting/AppTests.cs @@ -40,15 +40,9 @@ async Task StopHost() { if(_host.IsValueCreated) { - try - { - var host = await _host.Value; - - await host.Disconnect(); - } - catch(Xunit.Sdk.SkipException) - { - } + var host = await _host.Value; + + await host.Disconnect(); } } diff --git a/tests/Totem.Timeline.IntegrationTests/Cause.cs b/tests/Totem.Timeline.IntegrationTests/Cause.cs index bd70e377..a6f74178 100644 --- a/tests/Totem.Timeline.IntegrationTests/Cause.cs +++ b/tests/Totem.Timeline.IntegrationTests/Cause.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; +using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -8,7 +9,7 @@ namespace Totem.Timeline.IntegrationTests /// public class Cause : IntegrationTest { - [EventStoreFact] + [Fact] public async Task SetByWhen() { await Append(new StartTest()); diff --git a/tests/Totem.Timeline.IntegrationTests/GivenWhenOrder.cs b/tests/Totem.Timeline.IntegrationTests/GivenWhenOrder.cs index 0741e75d..f335b07f 100644 --- a/tests/Totem.Timeline.IntegrationTests/GivenWhenOrder.cs +++ b/tests/Totem.Timeline.IntegrationTests/GivenWhenOrder.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; +using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -8,7 +9,7 @@ namespace Totem.Timeline.IntegrationTests /// public class GivenWhenOrder : IntegrationTest { - [EventStoreFact] + [Fact] public async Task GivenBeforeWhenForSameEvent() { await Append(new Increment()); diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreFactAttribute.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreFactAttribute.cs deleted file mode 100644 index cec40eb7..00000000 --- a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreFactAttribute.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using Xunit; - -namespace Totem.Timeline.IntegrationTests.Hosting -{ - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - internal sealed class EventStoreFactAttribute : FactAttribute - { - const string SkipMessage = "Integration tests require eventStoreProcess:exeFile to be set to an existing EventStoreDB executable."; - - public EventStoreFactAttribute() - { - if(string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("EventStoreExeFile"))) - { - Skip = SkipMessage; - } - } - } -} diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs index 576ca42f..433820d6 100644 --- a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs +++ b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs @@ -29,11 +29,6 @@ internal EventStoreProcessCommand(string exeFile, int tcpPort, int httpPort) internal async Task StartProcess() { - if(string.IsNullOrWhiteSpace(_exeFile)) - { - return Process.GetCurrentProcess(); - } - if(IsFirstCommand()) { KillExistingProcesses(); @@ -66,9 +61,7 @@ void KillExistingProcesses() } IEnumerable GetExistingProcesses() => - string.IsNullOrWhiteSpace(_exeFile) - ? Array.Empty() - : Process.GetProcessesByName(Path.GetFileNameWithoutExtension(_exeFile)); + Process.GetProcessesByName(Path.GetFileNameWithoutExtension(_exeFile)); ProcessStartInfo CreateStartInfo() => new ProcessStartInfo diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs index 54b15136..eed07f63 100644 --- a/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs +++ b/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Reflection; using System.Threading; using Microsoft.Extensions.Configuration; @@ -59,18 +58,6 @@ static IServiceCollection AddEventStoreProcess(this IServiceCollection services) var processOptions = p.GetOptions(); var timelineOptions = p.GetOptions(); - if(string.IsNullOrWhiteSpace(processOptions.ExeFile) || - processOptions.ExeFile == "" || - !File.Exists(processOptions.ExeFile)) - { - processOptions.ExeFile = null; - } - - if(string.IsNullOrWhiteSpace(processOptions.ExeFile)) - { - return new EventStoreProcess(new EventStoreProcessCommand(processOptions.ExeFile, 0, 0), processOptions.ReadyDelay); - } - var command = new EventStoreProcessCommand( processOptions.ExeFile, timelineOptions.Server.TcpPort, diff --git a/tests/Totem.Timeline.IntegrationTests/MultipleWhenEvents.cs b/tests/Totem.Timeline.IntegrationTests/MultipleWhenEvents.cs index 6d238e52..2ddd6936 100644 --- a/tests/Totem.Timeline.IntegrationTests/MultipleWhenEvents.cs +++ b/tests/Totem.Timeline.IntegrationTests/MultipleWhenEvents.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; +using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -8,7 +9,7 @@ namespace Totem.Timeline.IntegrationTests /// public class MultipleWhenEvents : IntegrationTest { - [EventStoreFact] + [Fact] public async Task WrittenAfterWhen() { await Append(new StartTest()); diff --git a/tests/Totem.Timeline.IntegrationTests/Position.cs b/tests/Totem.Timeline.IntegrationTests/Position.cs index 62b1b819..539b9b14 100644 --- a/tests/Totem.Timeline.IntegrationTests/Position.cs +++ b/tests/Totem.Timeline.IntegrationTests/Position.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; +using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -8,7 +9,7 @@ namespace Totem.Timeline.IntegrationTests /// public class Position : IntegrationTest { - [EventStoreFact] + [Fact] public async Task IncreasesFrom0() { var position0 = await Append(new Happened()); diff --git a/tests/Totem.Timeline.IntegrationTests/Queries.cs b/tests/Totem.Timeline.IntegrationTests/Queries.cs index f782d3d0..8a2cc00c 100644 --- a/tests/Totem.Timeline.IntegrationTests/Queries.cs +++ b/tests/Totem.Timeline.IntegrationTests/Queries.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; +using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -8,7 +9,7 @@ namespace Totem.Timeline.IntegrationTests /// public class Queries : IntegrationTest { - [EventStoreFact] + [Fact] public async Task StateUpdatesAfterGiven() { var initial = await GetQuery(); diff --git a/tests/Totem.Timeline.IntegrationTests/RoutingToLatentInstance.cs b/tests/Totem.Timeline.IntegrationTests/RoutingToLatentInstance.cs index 279177ea..db0cfc95 100644 --- a/tests/Totem.Timeline.IntegrationTests/RoutingToLatentInstance.cs +++ b/tests/Totem.Timeline.IntegrationTests/RoutingToLatentInstance.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; +using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -8,7 +9,7 @@ namespace Totem.Timeline.IntegrationTests /// public class RoutingToLatentInstance : IntegrationTest { - [EventStoreFact] + [Fact] public async Task OnlyCreatesForRouteFirst() { var id = Id.From("A"); diff --git a/tests/Totem.Timeline.IntegrationTests/RoutingToMultiInstance.cs b/tests/Totem.Timeline.IntegrationTests/RoutingToMultiInstance.cs index 8907507e..47a527a9 100644 --- a/tests/Totem.Timeline.IntegrationTests/RoutingToMultiInstance.cs +++ b/tests/Totem.Timeline.IntegrationTests/RoutingToMultiInstance.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; +using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -10,7 +11,7 @@ namespace Totem.Timeline.IntegrationTests /// public class RoutingToMultiInstance : IntegrationTest { - [EventStoreFact] + [Fact] public async Task CreatesAllInstances() { var ids = Many.Of(Id.From("A"), Id.From("B"), Id.From("C")); diff --git a/tests/Totem.Timeline.IntegrationTests/ScheduledEvents.cs b/tests/Totem.Timeline.IntegrationTests/ScheduledEvents.cs index 9037a02e..3cd423ed 100644 --- a/tests/Totem.Timeline.IntegrationTests/ScheduledEvents.cs +++ b/tests/Totem.Timeline.IntegrationTests/ScheduledEvents.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Totem.Timeline.IntegrationTests.Hosting; +using Xunit; namespace Totem.Timeline.IntegrationTests { @@ -9,7 +10,7 @@ namespace Totem.Timeline.IntegrationTests /// public class ScheduledEvents : IntegrationTest { - [EventStoreFact] + [Fact] public async Task OccurWithin50Ms() { await Append(new StartTimer()); From 25d1c4a006a9e2cb8d94b19bff0229369aa8c110 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Fri, 6 Feb 2026 09:54:37 -0500 Subject: [PATCH 16/30] Return to expression body methods in the app host test file. --- src/Totem.App.Tests/Hosting/AppHost.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Totem.App.Tests/Hosting/AppHost.cs b/src/Totem.App.Tests/Hosting/AppHost.cs index 437a962a..dabc4192 100644 --- a/src/Totem.App.Tests/Hosting/AppHost.cs +++ b/src/Totem.App.Tests/Hosting/AppHost.cs @@ -30,10 +30,8 @@ protected override Task Close() return _shutdown.Task; } - void BuildAndRun() - { + void BuildAndRun() => CreateBuilder().Build().RunAsync().ContinueWith(StopHost); - } protected abstract IHostBuilder CreateBuilder(); From 2ade7f4f65c410322a289fa2089b8638b8d587bd Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Mon, 16 Feb 2026 15:48:28 -0500 Subject: [PATCH 17/30] Revert AppHost.Open to return instead of await The SkipException handling that required await was removed in a prior commit. Simplify back to returning the task directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Totem.App.Tests/Hosting/AppHost.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Totem.App.Tests/Hosting/AppHost.cs b/src/Totem.App.Tests/Hosting/AppHost.cs index dabc4192..5e7954a3 100644 --- a/src/Totem.App.Tests/Hosting/AppHost.cs +++ b/src/Totem.App.Tests/Hosting/AppHost.cs @@ -16,11 +16,11 @@ public abstract class AppHost : Connection readonly TaskSource _shutdown = new TaskSource(); IHostApplicationLifetime _lifetimeService; - protected override async Task Open() + protected override Task Open() { BuildAndRun(); - await _startup.Task; + return _startup.Task; } protected override Task Close() From f2ecab11baf3966737f8db359955b46d81da518a Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Mon, 16 Feb 2026 09:08:06 -0500 Subject: [PATCH 18/30] Add Outermind projects, update solution, and ignore copilot session files --- .gitignore | 3 ++- Totem.sln | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index a2f33574..7459b79a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ nuget.exe *.*sdf *.ipch /*.vspx -*.metaproj* \ No newline at end of file +*.metaproj* +copilot-session-*.md diff --git a/Totem.sln b/Totem.sln index 7561eaf3..cb7e9b68 100644 --- a/Totem.sln +++ b/Totem.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27428.2037 +# Visual Studio Version 18 +VisualStudioVersion = 18.2.11415.280 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Totem", "src\Totem\Totem.csproj", "{591C4A49-8193-4780-BEF3-F3420EC3C65C}" EndProject @@ -25,6 +25,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Totem.Timeline.IntegrationT EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Totem.App.Tests", "src\Totem.App.Tests\Totem.App.Tests.csproj", "{AD26407A-1944-4507-A296-56338B4AF0EA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Quantum", "Outermind\Quantum.csproj", "{89918063-7F9C-CBEB-B994-FF7BF0890257}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Quantum.Service", "Outermind.Service\Quantum.Service.csproj", "{FBE4A23E-67C6-598A-4049-9E22DE4CC45D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Quantum.Web", "Outermind.Web\Quantum.Web.csproj", "{861C1243-1602-028F-98DA-4C83BEE0FF51}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +77,18 @@ Global {AD26407A-1944-4507-A296-56338B4AF0EA}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD26407A-1944-4507-A296-56338B4AF0EA}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD26407A-1944-4507-A296-56338B4AF0EA}.Release|Any CPU.Build.0 = Release|Any CPU + {89918063-7F9C-CBEB-B994-FF7BF0890257}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89918063-7F9C-CBEB-B994-FF7BF0890257}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89918063-7F9C-CBEB-B994-FF7BF0890257}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89918063-7F9C-CBEB-B994-FF7BF0890257}.Release|Any CPU.Build.0 = Release|Any CPU + {FBE4A23E-67C6-598A-4049-9E22DE4CC45D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBE4A23E-67C6-598A-4049-9E22DE4CC45D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBE4A23E-67C6-598A-4049-9E22DE4CC45D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBE4A23E-67C6-598A-4049-9E22DE4CC45D}.Release|Any CPU.Build.0 = Release|Any CPU + {861C1243-1602-028F-98DA-4C83BEE0FF51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {861C1243-1602-028F-98DA-4C83BEE0FF51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {861C1243-1602-028F-98DA-4C83BEE0FF51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {861C1243-1602-028F-98DA-4C83BEE0FF51}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From d2aec5b263f266df269bf7accdf9b3221dc6bc4d Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Mon, 16 Feb 2026 09:24:44 -0500 Subject: [PATCH 19/30] Migrate Totem.Timeline.EventStore from TCP to gRPC client --- .../Client/ClientDb.cs | 33 ++--- .../Client/ClientSubscription.cs | 31 +++-- .../DbOperations/ReadFlowCommand.cs | 31 +++-- .../DbOperations/ReadFlowToResumeCommand.cs | 58 ++++++--- .../DbOperations/ReadResumeScheduleCommand.cs | 49 +++++--- .../DbOperations/SubscribeCommand.cs | 34 +++--- .../EventStoreContext.cs | 107 ++--------------- .../EventStoreContextExtensions.cs | 58 ++++----- .../EventStoreClientServiceExtensions.cs | 5 +- .../Hosting/EventStoreLogAdapter.cs | 30 ----- .../Hosting/EventStoreServiceExtensions.cs | 113 ++++-------------- .../Hosting/EventStoreTimelineOptions.cs | 39 +----- .../ResumeProjection.cs | 22 ++-- src/Totem.Timeline.EventStore/TimelineDb.cs | 18 ++- .../TimelineSubscription.cs | 28 ++--- .../Totem.Timeline.EventStore.csproj | 10 +- .../Hosting/EventStoreProcessCommand.cs | 16 +-- .../IntegrationAppServiceExtensions.cs | 6 +- .../appsettings.json | 16 +-- 19 files changed, 250 insertions(+), 454 deletions(-) delete mode 100644 src/Totem.Timeline.EventStore/Hosting/EventStoreLogAdapter.cs diff --git a/src/Totem.Timeline.EventStore/Client/ClientDb.cs b/src/Totem.Timeline.EventStore/Client/ClientDb.cs index e8eafbb1..60c2e32a 100644 --- a/src/Totem.Timeline.EventStore/Client/ClientDb.cs +++ b/src/Totem.Timeline.EventStore/Client/ClientDb.cs @@ -1,7 +1,8 @@ using System; using System.IO; +using System.Linq; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Totem.Runtime; using Totem.Runtime.Json; using Totem.Timeline.Client; @@ -66,7 +67,7 @@ public async Task WriteEvent(Event e) var result = await _context.AppendToTimeline(data); - return new TimelinePosition(result.NextExpectedVersion); + return new TimelinePosition((long)result.NextExpectedStreamRevision.ToUInt64()); } public Task ReadQuery(FlowKey key) => @@ -79,18 +80,22 @@ async Task ReadQueryCheckpoint(FlowKey key, Func getD { var stream = key.GetCheckpointStream(); - var result = await _context.Connection.ReadEventAsync(stream, StreamPosition.End, resolveLinkTos: false); + var result = _context.Client.ReadStreamAsync(Direction.Backwards, stream, StreamPosition.End, maxCount: 1); - switch(result.Status) + if(await result.ReadState == ReadState.StreamNotFound) { - case EventReadStatus.NoStream: - case EventReadStatus.NotFound: - return getDefault(); - case EventReadStatus.Success: - return getCheckpoint(result.Event.Value); - default: - throw new Exception($"Unexpected result when reading {stream}: {result.Status}"); + return getDefault(); } + + var events = new System.Collections.Generic.List(); + await foreach(var e in result) events.Add(e); + + if(events.Count == 0) + { + return getDefault(); + } + + return getCheckpoint(events[0]); } Query GetDefaultContent(FlowKey key) @@ -111,7 +116,7 @@ Query GetCheckpointContent(FlowKey key, ResolvedEvent e) throw new Exception($"Query is stopped at {metadata.ErrorPosition} with the following error: {metadata.ErrorMessage}"); } - var query = (Query) _context.Json.FromJsonUtf8(e.Event.Data, key.Type.DeclaredType); + var query = (Query) _context.Json.FromJsonUtf8(e.Event.Data.ToArray(), key.Type.DeclaredType); FlowContext.Bind(query, key, metadata.Position, metadata.ErrorPosition); @@ -134,11 +139,11 @@ QueryContent GetCheckpointContent(QueryETag etag, ResolvedEvent e) throw new Exception($"Query is stopped at {metadata.ErrorPosition} with the following error: {metadata.ErrorMessage}"); } - var checkpoint = new TimelinePosition(e.Event.EventNumber); + var checkpoint = new TimelinePosition((long)e.Event.EventNumber.ToUInt64()); return checkpoint == etag.Checkpoint ? new QueryContent(etag) - : new QueryContent(etag.WithCheckpoint(checkpoint), new MemoryStream(e.Event.Data)); + : new QueryContent(etag.WithCheckpoint(checkpoint), new MemoryStream(e.Event.Data.ToArray())); } } } \ No newline at end of file diff --git a/src/Totem.Timeline.EventStore/Client/ClientSubscription.cs b/src/Totem.Timeline.EventStore/Client/ClientSubscription.cs index e354fa66..782b6dd9 100644 --- a/src/Totem.Timeline.EventStore/Client/ClientSubscription.cs +++ b/src/Totem.Timeline.EventStore/Client/ClientSubscription.cs @@ -1,6 +1,7 @@ using System; +using System.Threading; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Totem.Runtime.Json; using Totem.Timeline.Client; @@ -13,8 +14,8 @@ internal sealed class ClientSubscription : IDisposable { readonly EventStoreContext _context; readonly IClientObserver _observer; - EventStoreSubscription _timelineSubscription; - EventStoreSubscription _clientSubscription; + StreamSubscription _timelineSubscription; + StreamSubscription _clientSubscription; internal ClientSubscription(EventStoreContext context, IClientObserver observer) { @@ -36,30 +37,26 @@ internal async Task Subscribe() async Task SubscribeToTimeline() { - var task = _context.Connection.SubscribeToStreamAsync( + _timelineSubscription = await _context.Client.SubscribeToStreamAsync( TimelineStreams.Timeline, - resolveLinkTos: false, - eventAppeared: (_, e) => OnNextFromTimeline(e), - subscriptionDropped: (_, reason, error) => OnDropped(reason, error)); - - _timelineSubscription = await task.ConfigureAwait(false); + FromStream.End, + eventAppeared: async (sub, e, ct) => await OnNextFromTimeline(e), + subscriptionDropped: (sub, reason, error) => OnDropped(reason, error)); } async Task SubscribeToClient() { - var task = _context.Connection.SubscribeToStreamAsync( + _clientSubscription = await _context.Client.SubscribeToStreamAsync( TimelineStreams.Client, - resolveLinkTos: false, - eventAppeared: (_, e) => OnNextFromClient(e), - subscriptionDropped: (_, reason, error) => OnDropped(reason, error)); - - _clientSubscription = await task.ConfigureAwait(false); + FromStream.End, + eventAppeared: async (sub, e, ct) => await OnNextFromClient(e), + subscriptionDropped: (sub, reason, error) => OnDropped(reason, error)); } Task OnNextFromTimeline(ResolvedEvent e) => _observer.OnNext(_context.ReadAreaPoint(e)); - void OnDropped(SubscriptionDropReason reason, Exception error) => + void OnDropped(SubscriptionDroppedReason reason, Exception error) => _observer.OnDropped(reason.ToString(), error); Task OnNextFromClient(ResolvedEvent e) @@ -78,7 +75,7 @@ Task OnNextFromClient(ResolvedEvent e) } T ReadEvent(ResolvedEvent e) => - _context.Json.FromJsonUtf8(e.Event.Data); + _context.Json.FromJsonUtf8(e.Event.Data.ToArray()); Task OnNext(CommandFailed e) => _observer.OnCommandFailed(e.CommandId, e.Error); diff --git a/src/Totem.Timeline.EventStore/DbOperations/ReadFlowCommand.cs b/src/Totem.Timeline.EventStore/DbOperations/ReadFlowCommand.cs index 2253c60c..ef902fa9 100644 --- a/src/Totem.Timeline.EventStore/DbOperations/ReadFlowCommand.cs +++ b/src/Totem.Timeline.EventStore/DbOperations/ReadFlowCommand.cs @@ -1,6 +1,7 @@ using System; +using System.Linq; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Totem.Runtime.Json; using Totem.Timeline.Runtime; @@ -26,21 +27,25 @@ internal async Task Execute() { var stream = _key.GetCheckpointStream(); - var result = await _context.Connection.ReadEventAsync(stream, StreamPosition.End, resolveLinkTos: false); + var result = _context.Client.ReadStreamAsync(Direction.Backwards, stream, StreamPosition.End, maxCount: 1); - switch(result.Status) + if(await result.ReadState == ReadState.StreamNotFound) { - case EventReadStatus.NoStream: - case EventReadStatus.NotFound: - return new FlowInfo.NotFound(); - case EventReadStatus.Success: - _checkpoint = result.Event.Value; - _metadata = _context.ReadCheckpointMetadata(_checkpoint); + return new FlowInfo.NotFound(); + } - return ReadFlow(); - default: - throw new Exception($"Unexpected result when reading {stream}: {result.Status}"); + var events = new System.Collections.Generic.List(); + await foreach(var e in result) events.Add(e); + + if(events.Count == 0) + { + return new FlowInfo.NotFound(); } + + _checkpoint = events[0]; + _metadata = _context.ReadCheckpointMetadata(_checkpoint); + + return ReadFlow(); } FlowInfo ReadFlow() @@ -69,6 +74,6 @@ Flow LoadFlow() } Flow ReadInstance() => - (Flow) _context.Json.FromJsonUtf8(_checkpoint.Event.Data, _key.Type.DeclaredType); + (Flow) _context.Json.FromJsonUtf8(_checkpoint.Event.Data.ToArray(), _key.Type.DeclaredType); } } \ No newline at end of file diff --git a/src/Totem.Timeline.EventStore/DbOperations/ReadFlowToResumeCommand.cs b/src/Totem.Timeline.EventStore/DbOperations/ReadFlowToResumeCommand.cs index 432a1cef..296f4c30 100644 --- a/src/Totem.Timeline.EventStore/DbOperations/ReadFlowToResumeCommand.cs +++ b/src/Totem.Timeline.EventStore/DbOperations/ReadFlowToResumeCommand.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Totem.Timeline.Runtime; namespace Totem.Timeline.EventStore.DbOperations @@ -71,20 +73,32 @@ async Task Resume(Flow flow) async Task ReadPoints() { - var result = await ReadLastRoute(); + var lastRoute = await ReadLastRoute(); - if(result.Status == EventReadStatus.Success) + if(lastRoute != null) { - await ReadPoints(result.Event.Value); + await ReadPoints(lastRoute.Value); } } - Task ReadLastRoute() => - _context.Connection.ReadEventAsync(_routesStream, StreamPosition.End, resolveLinkTos: true); + async Task ReadLastRoute() + { + var result = _context.Client.ReadStreamAsync(Direction.Backwards, _routesStream, StreamPosition.End, maxCount: 1, resolveLinkTos: true); + + if(await result.ReadState == ReadState.StreamNotFound) + { + return null; + } + + var events = new List(); + await foreach(var e in result) events.Add(e); + + return events.Count > 0 ? events[0] : (ResolvedEvent?)null; + } async Task ReadPoints(ResolvedEvent lastRoute) { - if(_areaCheckpoint == null || _areaCheckpoint < lastRoute.Event.EventNumber) + if(_areaCheckpoint == null || _areaCheckpoint < (long)lastRoute.Event.EventNumber.ToUInt64()) { AddPoint(lastRoute); @@ -99,7 +113,7 @@ void AddPoint(ResolvedEvent e) { _points.Write.Insert(0, _context.ReadAreaPoint(e)); - _routesCheckpoint = e.Link.EventNumber; + _routesCheckpoint = (long)e.Link.EventNumber.ToUInt64(); } async Task ReadNextBatch() @@ -111,9 +125,9 @@ async Task ReadNextBatch() var batch = await ReadBatch(); - foreach(var e in batch.Events) + foreach(var e in batch) { - if(e.Event.EventNumber <= _areaCheckpoint) + if((long)e.Event.EventNumber.ToUInt64() <= _areaCheckpoint) { return false; } @@ -121,23 +135,31 @@ async Task ReadNextBatch() AddPoint(e); } - return !batch.IsEndOfStream; + // If fewer results than requested, we've reached the end + var batchSize = _key.Type.ResumeAlgorithm.GetNextBatchSize(_batchIndex); + return batch.Count >= batchSize; } - async Task ReadBatch() + async Task> ReadBatch() { - var result = await _context.Connection.ReadStreamEventsBackwardAsync( + var batchSize = _key.Type.ResumeAlgorithm.GetNextBatchSize(_batchIndex); + var startPos = new StreamPosition((ulong)(_routesCheckpoint - 1)); + + var result = _context.Client.ReadStreamAsync( + Direction.Backwards, _routesStream, - start: _routesCheckpoint - 1, - count: _key.Type.ResumeAlgorithm.GetNextBatchSize(_batchIndex), + startPos, + maxCount: batchSize, resolveLinkTos: true); - if(result.Status != SliceReadStatus.Success) + if(await result.ReadState == ReadState.StreamNotFound) { - throw new Exception($"Unexpected result when reading {_routesStream} to resume: {result.Status}"); + throw new Exception($"Unexpected result when reading {_routesStream} to resume: stream not found"); } - return result; + var events = new List(); + await foreach(var e in result) events.Add(e); + return events; } } } \ No newline at end of file diff --git a/src/Totem.Timeline.EventStore/DbOperations/ReadResumeScheduleCommand.cs b/src/Totem.Timeline.EventStore/DbOperations/ReadResumeScheduleCommand.cs index c41deb7f..5621faf1 100644 --- a/src/Totem.Timeline.EventStore/DbOperations/ReadResumeScheduleCommand.cs +++ b/src/Totem.Timeline.EventStore/DbOperations/ReadResumeScheduleCommand.cs @@ -1,7 +1,8 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; namespace Totem.Timeline.EventStore.DbOperations { @@ -16,7 +17,7 @@ internal class ReadResumeScheduleCommand readonly Many _schedule; readonly long _scheduleFirst; readonly long _scheduleLast; - long _readCheckpoint; + long _readCheckpoint = -1; int _batchIndex; internal ReadResumeScheduleCommand(EventStoreContext context, Many schedule) @@ -27,8 +28,6 @@ internal ReadResumeScheduleCommand(EventStoreContext context, Many schedul _scheduleFirst = schedule.First(); _scheduleLast = schedule.Last(); - _readCheckpoint = StreamPosition.End; - // There is overhead in piping a resume algorithm from configuration. The default should work until // we experience otherwise. @@ -54,11 +53,16 @@ async Task ReadNextBatch() var batch = await ReadBatch(); - foreach(var e in batch.Events) + if(batch == null || batch.Count == 0) + { + return false; + } + + foreach(var e in batch) { - _readCheckpoint = e.Link.EventNumber; + _readCheckpoint = (long)e.Link.EventNumber.ToUInt64(); - var areaPosition = e.Event.EventNumber; + var areaPosition = (long)e.Event.EventNumber.ToUInt64(); if(areaPosition < _scheduleFirst) { @@ -71,25 +75,34 @@ async Task ReadNextBatch() } } - return !batch.IsEndOfStream; + // If fewer results than requested, we've reached the end + var batchSize = _algorithm.GetNextBatchSize(_batchIndex); + return batch.Count >= batchSize; } - async Task ReadBatch() + async Task> ReadBatch() { - var result = await _context.Connection.ReadStreamEventsBackwardAsync( + var startPos = _readCheckpoint < 0 + ? StreamPosition.End + : new StreamPosition((ulong)_readCheckpoint); + + var result = _context.Client.ReadStreamAsync( + Direction.Backwards, TimelineStreams.Schedule, - _readCheckpoint, - _algorithm.GetNextBatchSize(_batchIndex), + startPos, + maxCount: _algorithm.GetNextBatchSize(_batchIndex), resolveLinkTos: true); - switch(result.Status) + var readState = await result.ReadState; + + if(readState == ReadState.StreamNotFound) { - case SliceReadStatus.StreamNotFound: - case SliceReadStatus.Success: - return result; - default: - throw new Exception($"Unexpected result when reading {TimelineStreams.Schedule} to resume: {result.Status}"); + return new List(); } + + var events = new List(); + await foreach(var e in result) events.Add(e); + return events; } } } \ No newline at end of file diff --git a/src/Totem.Timeline.EventStore/DbOperations/SubscribeCommand.cs b/src/Totem.Timeline.EventStore/DbOperations/SubscribeCommand.cs index 658c56b5..e7a27fac 100644 --- a/src/Totem.Timeline.EventStore/DbOperations/SubscribeCommand.cs +++ b/src/Totem.Timeline.EventStore/DbOperations/SubscribeCommand.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Newtonsoft.Json.Linq; using Totem.Reflection; using Totem.Runtime.Json; @@ -17,39 +17,41 @@ internal class SubscribeCommand { readonly EventStoreContext _context; readonly ITimelineObserver _observer; - readonly CatchUpSubscriptionSettings _settings; internal SubscribeCommand( EventStoreContext context, - CatchUpSubscriptionSettings settings, ITimelineObserver observer) { _context = context; - _settings = settings; _observer = observer; } internal async Task Execute() { - var result = await _context.Connection.ReadEventAsync( + var result = _context.Client.ReadStreamAsync( + Direction.Backwards, TimelineStreams.Resume, StreamPosition.End, - resolveLinkTos: false); + maxCount: 1); - switch(result.Status) + if(await result.ReadState == ReadState.StreamNotFound) { - case EventReadStatus.NoStream: - case EventReadStatus.NotFound: - return ReadInitialResumeInfo(); - case EventReadStatus.Success: - return await ReadResumeInfo(result.Event?.Event.Data); - default: - throw new Exception($"Unexpected result when reading {TimelineStreams.Resume} to resume: {result.Status}"); + return ReadInitialResumeInfo(); } + + var events = new System.Collections.Generic.List(); + await foreach(var e in result) events.Add(e); + + if(events.Count == 0) + { + return ReadInitialResumeInfo(); + } + + return await ReadResumeInfo(events[0].Event.Data.ToArray()); } ResumeInfo ReadInitialResumeInfo() => - new ResumeInfo(new TimelineSubscription(_context, _settings, TimelinePosition.None, _observer)); + new ResumeInfo(new TimelineSubscription(_context, TimelinePosition.None, _observer)); async Task ReadResumeInfo(byte[] data) { @@ -59,7 +61,7 @@ async Task ReadResumeInfo(byte[] data) var routes = ReadResumeFlows(json["routes"].Value()).ToMany(); var schedule = await ReadResumeSchedule(json["schedule"].Value()); - var subscription = new TimelineSubscription(_context, _settings, checkpoint, _observer); + var subscription = new TimelineSubscription(_context, checkpoint, _observer); return new ResumeInfo(checkpoint, routes, schedule, subscription); } diff --git a/src/Totem.Timeline.EventStore/EventStoreContext.cs b/src/Totem.Timeline.EventStore/EventStoreContext.cs index baf7423d..d9a1740a 100644 --- a/src/Totem.Timeline.EventStore/EventStoreContext.cs +++ b/src/Totem.Timeline.EventStore/EventStoreContext.cs @@ -1,10 +1,7 @@ -using System; -using System.Reactive.Linq; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Totem.Runtime; using Totem.Runtime.Json; -using Totem.Threading; using Totem.Timeline.Area; namespace Totem.Timeline.EventStore @@ -14,119 +11,29 @@ namespace Totem.Timeline.EventStore /// public class EventStoreContext : Connection { - TaskSource _connectInitially; - - public EventStoreContext(IEventStoreConnection connection, IJsonFormat json, AreaMap area) + public EventStoreContext(EventStoreClient client, IJsonFormat json, AreaMap area) { - Connection = connection; + Client = client; Json = json; Area = area; } - public readonly IEventStoreConnection Connection; + public readonly EventStoreClient Client; public readonly IJsonFormat Json; public readonly AreaMap Area; protected override Task Open() { - ObserveConnected(); - ObserveDisconnected(); - ObserveReconnecting(); - ObserveClosed(); - ObserveErrorOccurred(); - ObserveAuthenticationFailed(); + Log.Info("Connected to EventStore via gRPC client"); - return ConnectInitially(); + return base.Open(); } protected override Task Close() { - Connection.Close(); + Client.Dispose(); return base.Close(); } - - void ObserveConnected() => - Observe( - e => Connection.Connected += e, - e => Connection.Connected -= e, - args => - { - Log.Info("Connected to EventStore at {EndPoint}", args.RemoteEndPoint); - - _connectInitially?.SetResult(); - }); - - void ObserveDisconnected() => - Observe( - e => Connection.Disconnected += e, - e => Connection.Disconnected -= e, - args => Log.Info("Disconnected from EventStore")); - - void ObserveReconnecting() => - Observe( - e => Connection.Reconnecting += e, - e => Connection.Reconnecting -= e, - args => Log.Info("Reconnecting to EventStore...")); - - void ObserveClosed() => - Observe( - e => Connection.Closed += e, - e => Connection.Closed -= e, - args => - { - Log.Info("EventStore connection closed ({Reason})", args.Reason); - - _connectInitially?.SetException(new Exception("EventStore connection closed while connecting initially")); - }); - - void ObserveErrorOccurred() => - Observe( - e => Connection.ErrorOccurred += e, - e => Connection.ErrorOccurred -= e, - args => - { - Log.Error(args.Exception, "EventStore connection error occurred"); - - _connectInitially?.SetException(args.Exception); - }); - - void ObserveAuthenticationFailed() => - Observe( - e => Connection.AuthenticationFailed += e, - e => Connection.AuthenticationFailed -= e, - args => - { - Log.Error("EventStore connection failed to authenticate ({Reason})", args.Reason); - - _connectInitially?.SetException(new Exception($"EventStore connection failed to authenticate when connecting initially ({args.Reason})")); - }); - - void Observe( - Action> add, - Action> remove, - Action onNext) - { - Track(Observable - .FromEventPattern(add, remove) - .Select(e => e.EventArgs) - .Subscribe(onNext)); - } - - async Task ConnectInitially() - { - _connectInitially = new TaskSource(); - - try - { - await Connection.ConnectAsync(); - - await _connectInitially.Task; - } - finally - { - _connectInitially = null; - } - } } } \ No newline at end of file diff --git a/src/Totem.Timeline.EventStore/EventStoreContextExtensions.cs b/src/Totem.Timeline.EventStore/EventStoreContextExtensions.cs index f76aa3e1..a6954516 100644 --- a/src/Totem.Timeline.EventStore/EventStoreContextExtensions.cs +++ b/src/Totem.Timeline.EventStore/EventStoreContextExtensions.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Totem.Reflection; using Totem.Runtime.Json; using Totem.Timeline.Area; @@ -52,11 +52,11 @@ internal static EventData GetAreaEventData( metadata.ApplyRoutes(type, routes); return new EventData( - eventId.IsUnassigned ? Guid.NewGuid() : Guid.Parse(eventId.ToString()), + eventId.IsUnassigned ? Uuid.NewUuid() : Uuid.FromGuid(Guid.Parse(eventId.ToString())), type.ToString(), - isJson: true, - data: context.ToJson(e), - metadata: context.ToJson(metadata)); + context.ToJson(e), + context.ToJson(metadata), + "application/json"); } internal static Many GetNewEventData( @@ -92,25 +92,25 @@ internal static EventData GetScheduledEventData(this EventStoreContext context, internal static EventData GetCheckpointEventData(this EventStoreContext context, Flow flow) => new EventData( - Guid.NewGuid(), + Uuid.NewUuid(), "timeline:Checkpoint", - isJson: true, - data: context.ToJson(flow), - metadata: context.ToJson(new CheckpointMetadata + context.ToJson(flow), + context.ToJson(new CheckpointMetadata { Position = flow.Context.CheckpointPosition, ErrorPosition = flow.Context.ErrorPosition, ErrorMessage = flow.Context.ErrorMessage, IsDone = flow.Context.IsDone - })); + }), + "application/json"); internal static EventData GetClientEventData(this EventStoreContext context, Event e) => new EventData( - Guid.NewGuid(), + Uuid.NewUuid(), $"timeline:{e.GetType().Name}", - isJson: true, - data: context.ToJson(e), - metadata: null); + context.ToJson(e), + null, + "application/json"); // // Reads @@ -121,12 +121,12 @@ internal static TimelinePoint ReadAreaPoint(this EventStoreContext context, Reso var type = context.ReadEventType(e); return new TimelinePoint( - new TimelinePosition(e.Event.EventNumber), + new TimelinePosition((long)e.Event.EventNumber.ToUInt64()), metadata.Cause, type, metadata.When, metadata.WhenOccurs, - Id.From(e.Event.EventId), + Id.From(e.Event.EventId.ToGuid()), metadata.CommandId, metadata.UserId, metadata.Topic, @@ -148,41 +148,41 @@ internal static TimelinePoint ReadAreaPoint(this EventStoreContext context, Reso context.ReadAreaPoint(e, context.ReadAreaMetadata(e)); internal static AreaEventMetadata ReadAreaMetadata(this EventStoreContext context, ResolvedEvent e) => - context.Json.FromJsonUtf8(e.Event.Metadata); + context.Json.FromJsonUtf8(e.Event.Metadata.ToArray()); internal static CheckpointMetadata ReadCheckpointMetadata(this EventStoreContext context, ResolvedEvent e) => - context.Json.FromJsonUtf8(e.Event.Metadata); + context.Json.FromJsonUtf8(e.Event.Metadata.ToArray()); internal static EventType ReadEventType(this EventStoreContext context, ResolvedEvent e) => context.Area.Events.Get(TypeName.From(e.Event.EventType)); static Event ReadEvent(this EventStoreContext context, ResolvedEvent e, EventType type) => - (Event) context.Json.FromJsonUtf8(e.Event.Data, type.DeclaredType); + (Event) context.Json.FromJsonUtf8(e.Event.Data.ToArray(), type.DeclaredType); // // Appends // - static Task AppendEvent(this EventStoreContext context, string stream, EventData data) => - context.Connection.AppendToStreamAsync(stream, ExpectedVersion.Any, data); + static Task AppendEvent(this EventStoreContext context, string stream, EventData data) => + context.Client.AppendToStreamAsync(stream, StreamState.Any, new[] { data }); - internal static Task AppendToTimeline(this EventStoreContext context, IEnumerable data) => - context.Connection.AppendToStreamAsync(TimelineStreams.Timeline, ExpectedVersion.Any, data); + internal static Task AppendToTimeline(this EventStoreContext context, IEnumerable data) => + context.Client.AppendToStreamAsync(TimelineStreams.Timeline, StreamState.Any, data); - internal static Task AppendToTimeline(this EventStoreContext context, EventData data) => + internal static Task AppendToTimeline(this EventStoreContext context, EventData data) => context.AppendEvent(TimelineStreams.Timeline, data); - internal static Task AppendToCheckpoint(this EventStoreContext context, Flow flow) => + internal static Task AppendToCheckpoint(this EventStoreContext context, Flow flow) => context.AppendEvent(flow.Context.Key.GetCheckpointStream(), context.GetCheckpointEventData(flow)); - internal static Task AppendToClient(this EventStoreContext context, Event e) => - context.Connection.AppendToStreamAsync(TimelineStreams.Client, ExpectedVersion.Any, context.GetClientEventData(e)); + internal static Task AppendToClient(this EventStoreContext context, Event e) => + context.Client.AppendToStreamAsync(TimelineStreams.Client, StreamState.Any, new[] { context.GetClientEventData(e) }); // // Metadata // - internal static Task SetCheckpointStreamMetadata(this EventStoreContext context, Flow flow, StreamMetadata value) => - context.Connection.SetStreamMetadataAsync(flow.Context.Key.GetCheckpointStream(), ExpectedVersion.Any, value); + internal static Task SetCheckpointStreamMetadata(this EventStoreContext context, Flow flow, StreamMetadata value) => + context.Client.SetStreamMetadataAsync(flow.Context.Key.GetCheckpointStream(), StreamState.Any, value); } } \ No newline at end of file diff --git a/src/Totem.Timeline.EventStore/Hosting/EventStoreClientServiceExtensions.cs b/src/Totem.Timeline.EventStore/Hosting/EventStoreClientServiceExtensions.cs index 3b264726..def492ec 100644 --- a/src/Totem.Timeline.EventStore/Hosting/EventStoreClientServiceExtensions.cs +++ b/src/Totem.Timeline.EventStore/Hosting/EventStoreClientServiceExtensions.cs @@ -1,6 +1,6 @@ using System; using System.ComponentModel; -using EventStore.ClientAPI; +using EventStore.Client; using Microsoft.Extensions.DependencyInjection; using Totem.Runtime.Hosting; using Totem.Runtime.Json; @@ -21,9 +21,8 @@ public static IEventStoreTimelineClientBuilder AddEventStore(this ITimelineClien { client.ConfigureServices(services => services - .AddSingleton() .AddSingleton(p => new EventStoreContext( - p.BuildConnection(), + p.BuildClient(), p.GetRequiredService(), p.GetRequiredService())) .AddSingleton() diff --git a/src/Totem.Timeline.EventStore/Hosting/EventStoreLogAdapter.cs b/src/Totem.Timeline.EventStore/Hosting/EventStoreLogAdapter.cs deleted file mode 100644 index 87fb35a3..00000000 --- a/src/Totem.Timeline.EventStore/Hosting/EventStoreLogAdapter.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using EventStore.ClientAPI; -using Totem.Runtime; - -namespace Totem.Timeline.EventStore.Hosting -{ - /// - /// Writes log messages from the EventStore client to the runtime log - /// - public class EventStoreLogAdapter : Notion, ILogger - { - public void Debug(string format, params object[] args) => - Log.Debug(format, args); - - public void Debug(Exception ex, string format, params object[] args) => - Log.Debug(ex, format, args); - - public void Error(string format, params object[] args) => - Log.Error(format, args); - - public void Error(Exception ex, string format, params object[] args) => - Log.Error(ex, format, args); - - public void Info(string format, params object[] args) => - Log.Info(format, args); - - public void Info(Exception ex, string format, params object[] args) => - Log.Info(ex, format, args); - } -} \ No newline at end of file diff --git a/src/Totem.Timeline.EventStore/Hosting/EventStoreServiceExtensions.cs b/src/Totem.Timeline.EventStore/Hosting/EventStoreServiceExtensions.cs index b8d0643f..58bc7382 100644 --- a/src/Totem.Timeline.EventStore/Hosting/EventStoreServiceExtensions.cs +++ b/src/Totem.Timeline.EventStore/Hosting/EventStoreServiceExtensions.cs @@ -1,10 +1,8 @@ using System; using System.ComponentModel; -using System.Net; -using EventStore.ClientAPI; -using EventStore.ClientAPI.Projections; -using EventStore.ClientAPI.SystemData; +using EventStore.Client; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Totem.Runtime.Hosting; using Totem.Runtime.Json; using Totem.Timeline.Area; @@ -23,24 +21,20 @@ public static IEventStoreTimelineBuilder AddEventStore(this ITimelineBuilder tim { timeline.ConfigureServices(services => { - services.AddSingleton(); - services.AddSingleton(p => new TimelineDb( p.GetRequiredService(), - p.BuildSubscriptionSettings(), p.GetRequiredService())); services.AddSingleton(p => new EventStoreContext( - p.BuildConnection(), + p.BuildClient(), p.GetRequiredService(), p.GetRequiredService())); services.AddSingleton(p => new ResumeProjection( p.GetRequiredService(), - p.GetRequiredService(), - p.BuildProjectionsCredentials())); + p.BuildProjectionClient())); - services.AddSingleton(BuildProjectionsManager); + services.AddSingleton(p => p.BuildProjectionClient()); }); return new EventStoreTimelineBuilder(timeline); @@ -66,93 +60,38 @@ public IEventStoreTimelineBuilder ConfigureServices(Action c } } - public static IEventStoreConnection BuildConnection(this IServiceProvider provider) + public static EventStoreClient BuildClient(this IServiceProvider provider) { var options = provider.GetOptions(); - var settings = ConnectionSettings.Create(); - - if(options.Verbose) - { - settings.EnableVerboseLogging(); - } - - settings.UseCustomLogger(provider.GetRequiredService()); - - var connection = options.Connection; - var reconnects = options.Reconnects; - var heartbeat = options.Heartbeat; - var operations = options.Operations; - - settings.WithConnectionTimeoutOf(connection.Timeout); - - settings.SetDefaultUserCredentials(new UserCredentials( - connection.Username, - connection.Password)); - - settings.SetReconnectionDelayTo(reconnects.Delay); - - if(reconnects.Limit > 0) - { - settings.LimitReconnectionsTo(reconnects.Limit); - } - else - { - settings.KeepReconnecting(); - } - - settings.SetHeartbeatInterval(heartbeat.Interval); - settings.SetHeartbeatTimeout(heartbeat.Timeout); - - settings.LimitAttemptsForOperationTo(operations.AttemptLimit); - settings.LimitOperationsQueueTo(operations.QueueLimit); - settings.SetOperationTimeoutTo(operations.Timeout); - settings.SetTimeoutCheckPeriodTo(operations.TimeoutCheckPeriod); - - if(operations.FailOnNoServerResponse) - { - settings.FailOnNoServerResponse(); - } - - if(operations.RetryLimit > 0) - { - settings.LimitRetriesForOperationTo(operations.RetryLimit); - } - else - { - settings.KeepRetrying(); - } - - var uri = new Uri($"tcp://{options.Server.Name}:{options.Server.TcpPort}/"); - - return EventStoreConnection.Create(settings.Build(), uri); + var settings = BuildClientSettings(options, provider); + return new EventStoreClient(settings); } - static CatchUpSubscriptionSettings BuildSubscriptionSettings(this IServiceProvider provider) + internal static EventStoreProjectionManagementClient BuildProjectionClient(this IServiceProvider provider) { var options = provider.GetOptions(); - - return new CatchUpSubscriptionSettings( - options.Subscription.MaxLiveQueueSize, - options.Subscription.ReadBatchSize, - options.Verbose, - resolveLinkTos: false); + var settings = BuildClientSettings(options, provider); + return new EventStoreProjectionManagementClient(settings); } - static UserCredentials BuildProjectionsCredentials(this IServiceProvider provider) + static EventStoreClientSettings BuildClientSettings(EventStoreTimelineOptions options, IServiceProvider provider) { - var options = provider.GetOptions(); - - return new UserCredentials(options.Connection.Username, options.Connection.Password); - } + if(!string.IsNullOrEmpty(options.ConnectionString)) + { + var settings = EventStoreClientSettings.Create(options.ConnectionString); + settings.LoggerFactory = provider.GetService(); + return settings; + } - static ProjectionsManager BuildProjectionsManager(this IServiceProvider provider) - { - var options = provider.GetOptions(); + var tls = options.Server.Insecure ? "tls=false" : ""; + var connStr = string.IsNullOrEmpty(options.Connection.Username) + ? $"esdb://{options.Server.Name}:{options.Server.Port}?{tls}" + : $"esdb://{options.Connection.Username}:{options.Connection.Password}@{options.Server.Name}:{options.Server.Port}?{tls}"; - return new ProjectionsManager( - provider.GetRequiredService(), - new DnsEndPoint(options.Server.Name, options.Server.HttpPort), - options.Projections.InstallTimeout); + var s = EventStoreClientSettings.Create(connStr); + s.LoggerFactory = provider.GetService(); + s.DefaultDeadline = options.Connection.Timeout; + return s; } } } \ No newline at end of file diff --git a/src/Totem.Timeline.EventStore/Hosting/EventStoreTimelineOptions.cs b/src/Totem.Timeline.EventStore/Hosting/EventStoreTimelineOptions.cs index 1244673b..fea0ff82 100644 --- a/src/Totem.Timeline.EventStore/Hosting/EventStoreTimelineOptions.cs +++ b/src/Totem.Timeline.EventStore/Hosting/EventStoreTimelineOptions.cs @@ -8,54 +8,23 @@ namespace Totem.Timeline.EventStore.Hosting public class EventStoreTimelineOptions { public bool Verbose { get; set; } = false; + public string ConnectionString { get; set; } public ServerOptions Server { get; set; } = new ServerOptions(); public ConnectionOptions Connection { get; set; } = new ConnectionOptions(); - public ReconnectOptions Reconnects { get; set; } = new ReconnectOptions(); - public HeartbeatOptions Heartbeat { get; set; } = new HeartbeatOptions(); - public OperationOptions Operations { get; set; } = new OperationOptions(); - public SubscriptionOptions Subscription { get; set; } = new SubscriptionOptions(); public ProjectionOptions Projections { get; set; } = new ProjectionOptions(); public class ServerOptions { public string Name { get; set; } = "localhost"; - public int TcpPort { get; set; } = 1113; - public int HttpPort { get; set; } = 2113; + public int Port { get; set; } = 2113; + public bool Insecure { get; set; } = true; } public class ConnectionOptions { public string Username { get; set; } public string Password { get; set; } - public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); - } - - public class ReconnectOptions - { - public int Limit { get; set; } = 5; - public TimeSpan Delay { get; set; } = TimeSpan.FromMilliseconds(500); - } - - public class HeartbeatOptions - { - public TimeSpan Interval { get; set; } = TimeSpan.FromMilliseconds(750); - public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(1500); - } - - public class OperationOptions - { - public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(7); - public TimeSpan TimeoutCheckPeriod { get; set; } = TimeSpan.FromSeconds(1); - public int AttemptLimit { get; set; } = 11; - public int RetryLimit { get; set; } = 10; - public int QueueLimit { get; set; } = 5000; - public bool FailOnNoServerResponse { get; set; } = false; - } - - public class SubscriptionOptions - { - public int MaxLiveQueueSize { get; set; } = 100000; - public int ReadBatchSize { get; set; } = 500; + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10); } public class ProjectionOptions diff --git a/src/Totem.Timeline.EventStore/ResumeProjection.cs b/src/Totem.Timeline.EventStore/ResumeProjection.cs index 38a02e95..7cd45350 100644 --- a/src/Totem.Timeline.EventStore/ResumeProjection.cs +++ b/src/Totem.Timeline.EventStore/ResumeProjection.cs @@ -1,9 +1,8 @@ using System.IO; using System.Text; using System.Threading.Tasks; -using EventStore.ClientAPI.Exceptions; -using EventStore.ClientAPI.Projections; -using EventStore.ClientAPI.SystemData; +using EventStore.Client; +using Grpc.Core; using Totem.Runtime; using Totem.Timeline.Area; @@ -15,14 +14,12 @@ namespace Totem.Timeline.EventStore public sealed class ResumeProjection : Notion, IResumeProjection { readonly AreaMap _area; - readonly ProjectionsManager _manager; - readonly UserCredentials _credentials; + readonly EventStoreProjectionManagementClient _manager; - public ResumeProjection(AreaMap area, ProjectionsManager manager, UserCredentials credentials) + public ResumeProjection(AreaMap area, EventStoreProjectionManagementClient manager) { _area = area; _manager = manager; - _credentials = credentials; } public async Task Synchronize() @@ -37,24 +34,19 @@ async Task StreamNotFound() { try { - await _manager.GetStatusAsync(TimelineStreams.Resume, _credentials); + await _manager.GetStatusAsync(TimelineStreams.Resume); return false; } - catch(ProjectionCommandFailedException error) + catch(RpcException ex) when (ex.StatusCode == StatusCode.NotFound) { - if(error.HttpStatusCode != 404) - { - throw; - } - return true; } } async Task CreateStream() { - await _manager.CreateContinuousAsync(TimelineStreams.Resume, await ReadScript(), _credentials); + await _manager.CreateContinuousAsync(TimelineStreams.Resume, await ReadScript()); Log.Debug("[timeline] Created projection {Name}", TimelineStreams.Resume); } diff --git a/src/Totem.Timeline.EventStore/TimelineDb.cs b/src/Totem.Timeline.EventStore/TimelineDb.cs index 7c8e866e..ea0af70e 100644 --- a/src/Totem.Timeline.EventStore/TimelineDb.cs +++ b/src/Totem.Timeline.EventStore/TimelineDb.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Totem.Runtime; using Totem.Timeline.Client; using Totem.Timeline.EventStore.Client; @@ -15,13 +15,11 @@ namespace Totem.Timeline.EventStore public sealed class TimelineDb : Connection, ITimelineDb { readonly EventStoreContext _context; - readonly CatchUpSubscriptionSettings _subscriptionSettings; readonly IResumeProjection _resumeProjection; - public TimelineDb(EventStoreContext context, CatchUpSubscriptionSettings subscriptionSettings, IResumeProjection resumeProjection) + public TimelineDb(EventStoreContext context, IResumeProjection resumeProjection) { _context = context; - _subscriptionSettings = subscriptionSettings; _resumeProjection = resumeProjection; } @@ -33,7 +31,7 @@ protected override async Task Open() } public Task Subscribe(ITimelineObserver observer) => - new SubscribeCommand(_context, _subscriptionSettings, observer).Execute(); + new SubscribeCommand(_context, observer).Execute(); public Task ReadFlow(FlowKey key) => new ReadFlowCommand(_context, key).Execute(); @@ -47,7 +45,7 @@ public async Task WriteNewEvents(TimelinePosition cause, FlowK var result = await _context.AppendToTimeline(data); - return new TimelinePosition(result.NextExpectedVersion - newEvents.Count + 1); + return new TimelinePosition((long)result.NextExpectedStreamRevision.ToUInt64() - newEvents.Count + 1); } public Task WriteScheduledEvent(TimelinePoint cause) @@ -59,7 +57,7 @@ public Task WriteScheduledEvent(TimelinePoint cause) public async Task WriteCheckpoint(Flow flow, TimelinePoint point) { - WriteResult result; + IWriteResult result; try { @@ -77,7 +75,7 @@ public async Task WriteCheckpoint(Flow flow, TimelinePoint point) if(flow.Context.IsDone) { - await TryWriteDoneMetadata(flow, result.NextExpectedVersion); + await TryWriteDoneMetadata(flow, (long)result.NextExpectedStreamRevision.ToUInt64()); } } @@ -85,7 +83,7 @@ async Task TryWriteInitialMetadata(Flow flow) { try { - await _context.SetCheckpointStreamMetadata(flow, StreamMetadata.Create(maxCount: 1)); + await _context.SetCheckpointStreamMetadata(flow, new StreamMetadata(maxCount: 1)); } catch(Exception error) { @@ -97,7 +95,7 @@ async Task TryWriteDoneMetadata(Flow flow, long position) { try { - await _context.SetCheckpointStreamMetadata(flow, StreamMetadata.Create(maxCount: 1, truncateBefore: position)); + await _context.SetCheckpointStreamMetadata(flow, new StreamMetadata(maxCount: 1, truncateBefore: new StreamPosition((ulong)position))); } catch(Exception error) { diff --git a/src/Totem.Timeline.EventStore/TimelineSubscription.cs b/src/Totem.Timeline.EventStore/TimelineSubscription.cs index c95978d9..34e66881 100644 --- a/src/Totem.Timeline.EventStore/TimelineSubscription.cs +++ b/src/Totem.Timeline.EventStore/TimelineSubscription.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -using EventStore.ClientAPI; +using EventStore.Client; using Totem.Runtime; using Totem.Timeline.Runtime; @@ -11,40 +11,38 @@ namespace Totem.Timeline.EventStore public class TimelineSubscription : Connection { readonly EventStoreContext _context; - readonly CatchUpSubscriptionSettings _settings; readonly TimelinePosition _checkpoint; readonly ITimelineObserver _observer; - EventStoreCatchUpSubscription _subscription; + StreamSubscription _subscription; public TimelineSubscription( EventStoreContext context, - CatchUpSubscriptionSettings settings, TimelinePosition checkpoint, ITimelineObserver observer) { _context = context; - _settings = settings; _checkpoint = checkpoint; _observer = observer; } - protected override Task Open() + protected override async Task Open() { - _subscription = _context.Connection.SubscribeToStreamFrom( + var fromStream = _checkpoint.IsSome + ? FromStream.After(new StreamPosition((ulong)_checkpoint.ToInt64OrNull().Value)) + : FromStream.Start; + + _subscription = await _context.Client.SubscribeToStreamAsync( TimelineStreams.Timeline, - _checkpoint.ToInt64OrNull(), - _settings, - eventAppeared: (_, e) => - _observer.OnNext(_context.ReadAreaPoint(e)), - subscriptionDropped: (_, reason, error) => + fromStream, + eventAppeared: async (subscription, e, ct) => + await _observer.OnNext(_context.ReadAreaPoint(e)), + subscriptionDropped: (subscription, reason, error) => _observer.OnDropped(reason.ToString(), error)); - - return base.Open(); } protected override Task Close() { - _subscription?.Stop(); + _subscription?.Dispose(); return base.Close(); } diff --git a/src/Totem.Timeline.EventStore/Totem.Timeline.EventStore.csproj b/src/Totem.Timeline.EventStore/Totem.Timeline.EventStore.csproj index 1cf48dde..f4ebcd03 100644 --- a/src/Totem.Timeline.EventStore/Totem.Timeline.EventStore.csproj +++ b/src/Totem.Timeline.EventStore/Totem.Timeline.EventStore.csproj @@ -1,8 +1,7 @@ - netstandard2.0 - 7.1 + net10.0 Totem.Timeline.EventStore Totem Totem Contributors @@ -27,9 +26,10 @@ - - - + + + + diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs index 433820d6..369da69b 100644 --- a/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs +++ b/tests/Totem.Timeline.IntegrationTests/Hosting/EventStoreProcessCommand.cs @@ -17,14 +17,12 @@ internal sealed class EventStoreProcessCommand static int _firstCommandFlag; readonly string _exeFile; - readonly int _tcpPort; - readonly int _httpPort; + readonly int _port; - internal EventStoreProcessCommand(string exeFile, int tcpPort, int httpPort) + internal EventStoreProcessCommand(string exeFile, int port) { _exeFile = exeFile; - _tcpPort = tcpPort; - _httpPort = httpPort; + _port = port; } internal async Task StartProcess() @@ -73,12 +71,8 @@ ProcessStartInfo CreateStartInfo() => .Write("--mem-db") .Write(" --stats-period-sec=60") .Write(" --run-projections=all") - .Write(" --int-ip=127.0.0.1") - .Write(" --ext-ip=127.0.0.1") - .Write(" --int-tcp-port=").Write(_tcpPort) - .Write(" --ext-tcp-port=").Write(_tcpPort) - .Write(" --int-http-port=").Write(_httpPort) - .Write(" --ext-http-port=").Write(_httpPort), + .Write(" --insecure") + .Write(" --http-port=").Write(_port), }; } } diff --git a/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs b/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs index eed07f63..912cd5e2 100644 --- a/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs +++ b/tests/Totem.Timeline.IntegrationTests/Hosting/IntegrationAppServiceExtensions.cs @@ -60,8 +60,7 @@ static IServiceCollection AddEventStoreProcess(this IServiceCollection services) var command = new EventStoreProcessCommand( processOptions.ExeFile, - timelineOptions.Server.TcpPort, - timelineOptions.Server.HttpPort); + timelineOptions.Server.Port); return new EventStoreProcess(command, processOptions.ReadyDelay); }); @@ -72,8 +71,7 @@ static IServiceCollection ConfigureEventStorePorts(this IServiceCollection servi return services.Configure(options => { - options.Server.TcpPort += offset; - options.Server.HttpPort += offset; + options.Server.Port += offset; }); } } diff --git a/tests/Totem.Timeline.IntegrationTests/appsettings.json b/tests/Totem.Timeline.IntegrationTests/appsettings.json index 53951bcf..62c39064 100644 --- a/tests/Totem.Timeline.IntegrationTests/appsettings.json +++ b/tests/Totem.Timeline.IntegrationTests/appsettings.json @@ -2,25 +2,13 @@ "totem.timeline.eventStore": { "server": { "name": "localhost", - "tcpPort": 1113, - "httpPort": 2113 + "port": 2113, + "insecure": true }, "connection": { "username": "admin", "password": "changeit", "timeout": "00:00:10" - }, - "reconnects": { - "limit": 3, - "delay": "00:00:05" - }, - "heartbeat": { - "interval": "00:00:05", - "timeout": "00:00:10" - }, - "operations": { - "timeout": "00:00:10", - "timeoutCheckPeriod": "00:00:05" } }, "eventStoreProcess": { From dca2341fd441cb8f311615962185b2cbdc30cfd7 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Mon, 16 Feb 2026 11:45:18 -0500 Subject: [PATCH 20/30] Drop TLS credentials when insecure is true. --- .../Hosting/EventStoreServiceExtensions.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Totem.Timeline.EventStore/Hosting/EventStoreServiceExtensions.cs b/src/Totem.Timeline.EventStore/Hosting/EventStoreServiceExtensions.cs index 58bc7382..2e815071 100644 --- a/src/Totem.Timeline.EventStore/Hosting/EventStoreServiceExtensions.cs +++ b/src/Totem.Timeline.EventStore/Hosting/EventStoreServiceExtensions.cs @@ -83,10 +83,14 @@ static EventStoreClientSettings BuildClientSettings(EventStoreTimelineOptions op return settings; } + // Omit the credentials when insecure mode is true + var tls = options.Server.Insecure ? "tls=false" : ""; - var connStr = string.IsNullOrEmpty(options.Connection.Username) - ? $"esdb://{options.Server.Name}:{options.Server.Port}?{tls}" - : $"esdb://{options.Connection.Username}:{options.Connection.Password}@{options.Server.Name}:{options.Server.Port}?{tls}"; + var includeCredentials = !options.Server.Insecure && !string.IsNullOrEmpty(options.Connection.Username); + + var connStr = includeCredentials + ? $"esdb://{options.Connection.Username}:{options.Connection.Password}@{options.Server.Name}:{options.Server.Port}?{tls}" + : $"esdb://{options.Server.Name}:{options.Server.Port}?{tls}"; var s = EventStoreClientSettings.Create(connStr); s.LoggerFactory = provider.GetService(); From df36740e5701829f231a3578ece325229351263e Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Mon, 16 Feb 2026 15:09:40 -0500 Subject: [PATCH 21/30] Fixed the resume flow. --- src/Totem.Timeline.EventStore/resume-projection.js | 3 ++- src/Totem.Timeline/Runtime/TimelineHost.cs | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Totem.Timeline.EventStore/resume-projection.js b/src/Totem.Timeline.EventStore/resume-projection.js index 27617601..75cfafbe 100644 --- a/src/Totem.Timeline.EventStore/resume-projection.js +++ b/src/Totem.Timeline.EventStore/resume-projection.js @@ -23,7 +23,8 @@ function defaultState() { } function onNext(state, event) { - let { streamId, metadata } = event; + let { streamId, metadataRaw } = event; + let metadata = metadataRaw ? JSON.parse(metadataRaw) : {}; observe(); diff --git a/src/Totem.Timeline/Runtime/TimelineHost.cs b/src/Totem.Timeline/Runtime/TimelineHost.cs index 917cd3a0..a50faada 100644 --- a/src/Totem.Timeline/Runtime/TimelineHost.cs +++ b/src/Totem.Timeline/Runtime/TimelineHost.cs @@ -62,6 +62,8 @@ async Task ResumeSubscription() _schedule.Resume(resumeInfo.Schedule); + Log.Info("Resume: yes"); + return resumeInfo.Subscription; } From 686791419eda7e5893627e385034febcd5e2142e Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 08:54:07 -0500 Subject: [PATCH 22/30] Transition core standard libraries to net10. --- src/Totem.App.Tests/Totem.App.Tests.csproj | 3 +-- src/Totem.Runtime/Fields.cs | 2 +- src/Totem.Runtime/Totem.Runtime.csproj | 13 ++++++------- src/Totem.Timeline/Totem.Timeline.csproj | 5 ++--- src/Totem/Totem.csproj | 3 +-- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Totem.App.Tests/Totem.App.Tests.csproj b/src/Totem.App.Tests/Totem.App.Tests.csproj index 9a13b202..33acbd94 100644 --- a/src/Totem.App.Tests/Totem.App.Tests.csproj +++ b/src/Totem.App.Tests/Totem.App.Tests.csproj @@ -1,8 +1,7 @@  - netstandard2.0 - 7.1 + net10.0 Totem.App.Tests Totem Totem Contributors diff --git a/src/Totem.Runtime/Fields.cs b/src/Totem.Runtime/Fields.cs index da560673..f0df1e45 100644 --- a/src/Totem.Runtime/Fields.cs +++ b/src/Totem.Runtime/Fields.cs @@ -22,7 +22,7 @@ public Fields(IBindable binding) public IEnumerable Keys => _pairs.Keys; public IEnumerable Values => _pairs.Values; public IEnumerable<(Field, object)> Pairs => _pairs.Select(pair => (pair.Key, pair.Value)); - public IEnumerable Names => _pairs.Keys.Select(field => field.Name); + public IEnumerable Names => _pairs.Keys.Select(f => f.Name); public object this[Field field] { diff --git a/src/Totem.Runtime/Totem.Runtime.csproj b/src/Totem.Runtime/Totem.Runtime.csproj index acf11065..b28b4aca 100644 --- a/src/Totem.Runtime/Totem.Runtime.csproj +++ b/src/Totem.Runtime/Totem.Runtime.csproj @@ -1,8 +1,7 @@ - netstandard2.0 - 7.1 + net10.0 Totem.Runtime Totem Totem Contributors @@ -23,11 +22,11 @@ - - - - - + + + + + diff --git a/src/Totem.Timeline/Totem.Timeline.csproj b/src/Totem.Timeline/Totem.Timeline.csproj index aae5e357..e5071cd3 100644 --- a/src/Totem.Timeline/Totem.Timeline.csproj +++ b/src/Totem.Timeline/Totem.Timeline.csproj @@ -1,8 +1,7 @@ - netstandard2.0 - 7.1 + net10.0 Totem.Timeline Totem Totem Contributors @@ -23,7 +22,7 @@ - + diff --git a/src/Totem/Totem.csproj b/src/Totem/Totem.csproj index 61c81b44..bf89d00e 100644 --- a/src/Totem/Totem.csproj +++ b/src/Totem/Totem.csproj @@ -1,8 +1,7 @@ - netstandard2.0 - 7.1 + net10.0 Totem Totem Totem Contributors From 0135b1cd5130b0e6cc7c8c772b3397402284cefe Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 09:23:49 -0500 Subject: [PATCH 23/30] Phase 1: Upgrade TFMs to net10.0 and modernize obsolete APIs - Upgrade Totem, Totem.Runtime, Totem.Timeline, Totem.App.Tests from netstandard2.0 to net10.0 - Update Microsoft.Extensions.* package versions from 2.2.0 to 10.0.0 - Replace FormatterServices.GetUninitializedObject() with RuntimeHelpers.GetUninitializedObject() in DurableType.cs - Replace Assembly.LoadWithPartialName() with Assembly.Load(new AssemblyName()) in TypeResolver.cs - Remove ToHashSet extension methods that conflict with built-in LINQ on net10.0 - Fix C# 14 field keyword conflict in Fields.cs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Totem.App.Tests/Totem.App.Tests.csproj | 3 +-- src/Totem.Runtime/Fields.cs | 2 +- src/Totem.Runtime/Json/DurableType.cs | 4 ++-- src/Totem.Runtime/Totem.Runtime.csproj | 13 ++++++------- src/Totem.Timeline/Totem.Timeline.csproj | 5 ++--- src/Totem/ManyExtensions.cs | 8 +------- src/Totem/Reflection/TypeResolver.cs | 12 +++++++++--- src/Totem/Totem.csproj | 3 +-- 8 files changed, 23 insertions(+), 27 deletions(-) diff --git a/src/Totem.App.Tests/Totem.App.Tests.csproj b/src/Totem.App.Tests/Totem.App.Tests.csproj index 9a13b202..33acbd94 100644 --- a/src/Totem.App.Tests/Totem.App.Tests.csproj +++ b/src/Totem.App.Tests/Totem.App.Tests.csproj @@ -1,8 +1,7 @@  - netstandard2.0 - 7.1 + net10.0 Totem.App.Tests Totem Totem Contributors diff --git a/src/Totem.Runtime/Fields.cs b/src/Totem.Runtime/Fields.cs index da560673..f0df1e45 100644 --- a/src/Totem.Runtime/Fields.cs +++ b/src/Totem.Runtime/Fields.cs @@ -22,7 +22,7 @@ public Fields(IBindable binding) public IEnumerable Keys => _pairs.Keys; public IEnumerable Values => _pairs.Values; public IEnumerable<(Field, object)> Pairs => _pairs.Select(pair => (pair.Key, pair.Value)); - public IEnumerable Names => _pairs.Keys.Select(field => field.Name); + public IEnumerable Names => _pairs.Keys.Select(f => f.Name); public object this[Field field] { diff --git a/src/Totem.Runtime/Json/DurableType.cs b/src/Totem.Runtime/Json/DurableType.cs index cc71d808..663af7a6 100644 --- a/src/Totem.Runtime/Json/DurableType.cs +++ b/src/Totem.Runtime/Json/DurableType.cs @@ -1,6 +1,6 @@ using System; using System.Reflection; -using System.Runtime.Serialization; +using System.Runtime.CompilerServices; using Totem.Reflection; namespace Totem.Runtime.Json @@ -23,7 +23,7 @@ public override string ToString() => Key.ToString(); public object Create() => - FormatterServices.GetUninitializedObject(DeclaredType); + RuntimeHelpers.GetUninitializedObject(DeclaredType); // // Factory diff --git a/src/Totem.Runtime/Totem.Runtime.csproj b/src/Totem.Runtime/Totem.Runtime.csproj index acf11065..b28b4aca 100644 --- a/src/Totem.Runtime/Totem.Runtime.csproj +++ b/src/Totem.Runtime/Totem.Runtime.csproj @@ -1,8 +1,7 @@ - netstandard2.0 - 7.1 + net10.0 Totem.Runtime Totem Totem Contributors @@ -23,11 +22,11 @@ - - - - - + + + + + diff --git a/src/Totem.Timeline/Totem.Timeline.csproj b/src/Totem.Timeline/Totem.Timeline.csproj index aae5e357..e5071cd3 100644 --- a/src/Totem.Timeline/Totem.Timeline.csproj +++ b/src/Totem.Timeline/Totem.Timeline.csproj @@ -1,8 +1,7 @@ - netstandard2.0 - 7.1 + net10.0 Totem.Timeline Totem Totem Contributors @@ -23,7 +22,7 @@ - + diff --git a/src/Totem/ManyExtensions.cs b/src/Totem/ManyExtensions.cs index 9ab25108..d1b5b2e9 100644 --- a/src/Totem/ManyExtensions.cs +++ b/src/Totem/ManyExtensions.cs @@ -60,15 +60,9 @@ public static Many ToMany(this IEnumerable itemsBefore, T item0, T item Many.OfAll(itemsBefore, item0, item1, item2, item3, itemsAfter); // - // ToHashSet + // ToHashSet (1-arg and 2-arg overloads removed; built-in LINQ provides them on net10.0) // - public static HashSet ToHashSet(this IEnumerable items) => - new HashSet(items); - - public static HashSet ToHashSet(this IEnumerable items, IEqualityComparer comparer) => - new HashSet(items, comparer); - public static HashSet ToHashSet(this IEnumerable items, Func selectItem) => items.Select(selectItem).ToHashSet(); diff --git a/src/Totem/Reflection/TypeResolver.cs b/src/Totem/Reflection/TypeResolver.cs index 22299833..41706eb5 100644 --- a/src/Totem/Reflection/TypeResolver.cs +++ b/src/Totem/Reflection/TypeResolver.cs @@ -57,9 +57,15 @@ bool TryLoadType(string name, string assembly, out Type type) } else { -#pragma warning disable 618, 612 - var loadedAssembly = Assembly.LoadWithPartialName(assembly); -#pragma warning restore 618, 612 + Assembly loadedAssembly; + try + { + loadedAssembly = Assembly.Load(new AssemblyName(assembly)); + } + catch + { + loadedAssembly = null; + } if(loadedAssembly == null) { diff --git a/src/Totem/Totem.csproj b/src/Totem/Totem.csproj index 61c81b44..bf89d00e 100644 --- a/src/Totem/Totem.csproj +++ b/src/Totem/Totem.csproj @@ -1,8 +1,7 @@ - netstandard2.0 - 7.1 + net10.0 Totem Totem Totem Contributors From 343030a7bd60306deb4efae626e2aea65c50e4a8 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 09:29:00 -0500 Subject: [PATCH 24/30] Phase 2: Core System.Text.Json implementation Create new STJ infrastructure replacing Newtonsoft.Json core: - Add TotemJsonTypeInfoResolver: replaces JsonFormatContractResolver and JsonFormatSerializationBinder with DefaultJsonTypeInfoResolver + Modifiers - Many collection creation via expression-compiled factory - Durable type object creation via RuntimeHelpers.GetUninitializedObject() - Property filtering: excludes [Transient], [CompilerGenerated], Notion-declared - Private field/property serialization for durable types - [WriteOnly] attribute support (serialize but don't deserialize) - Add DurableTypeDiscriminatorConverter: JsonConverterFactory handling polymorphic \ property with 'durable:Prefix:TypeName' values for backward compatibility with Newtonsoft-serialized EventStore data. Includes TypeResolver fallback for legacy assembly-qualified type names. - Redesign IJsonFormat: expose JsonSerializerOptions instead of Apply() pattern - Rewrite JsonFormat: simple wrapper around JsonSerializerOptions - Rewrite JsonFormatExtensions: use JsonSerializer/JsonNode instead of JsonConvert/JObject. Add ToJsonNode/ToJsonNodeUtf8 methods replacing JObject. Implement CopyProperties for PopulateObject equivalent. - Rewrite JsonFormatOptions: SerializerOptions replaces SerializerSettings - Rewrite JsonFormatOptionsSetup: configure WriteIndented, CamelCase naming, JsonStringEnumConverter, TotemJsonTypeInfoResolver, DurableTypeDiscriminatorConverter - Update JsonServiceExtensions: use SerializerOptions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Hosting/JsonFormatOptions.cs | 4 +- .../Hosting/JsonFormatOptionsSetup.cs | 24 ++- .../Hosting/JsonServiceExtensions.cs | 2 +- .../Json/DurableTypeDiscriminatorConverter.cs | 142 ++++++++++++++++++ src/Totem.Runtime/Json/IJsonFormat.cs | 7 +- src/Totem.Runtime/Json/JsonFormat.cs | 15 +- .../Json/JsonFormatExtensions.cs | 71 ++++++--- .../Json/TotemJsonTypeInfoResolver.cs | 111 ++++++++++++++ 8 files changed, 323 insertions(+), 53 deletions(-) create mode 100644 src/Totem.Runtime/Json/DurableTypeDiscriminatorConverter.cs create mode 100644 src/Totem.Runtime/Json/TotemJsonTypeInfoResolver.cs diff --git a/src/Totem.Runtime/Hosting/JsonFormatOptions.cs b/src/Totem.Runtime/Hosting/JsonFormatOptions.cs index 2b0ef770..7cb20dbc 100644 --- a/src/Totem.Runtime/Hosting/JsonFormatOptions.cs +++ b/src/Totem.Runtime/Hosting/JsonFormatOptions.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json; using Totem.Runtime.Json; namespace Totem.Runtime.Hosting @@ -9,7 +9,7 @@ namespace Totem.Runtime.Hosting /// public class JsonFormatOptions { - public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings(); + public JsonSerializerOptions SerializerOptions { get; } = new JsonSerializerOptions(); public List DurableTypes { get; } = new List(); } } \ No newline at end of file diff --git a/src/Totem.Runtime/Hosting/JsonFormatOptionsSetup.cs b/src/Totem.Runtime/Hosting/JsonFormatOptionsSetup.cs index 64c9f6c3..5286fd99 100644 --- a/src/Totem.Runtime/Hosting/JsonFormatOptionsSetup.cs +++ b/src/Totem.Runtime/Hosting/JsonFormatOptionsSetup.cs @@ -1,36 +1,32 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Options; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; using Totem.Runtime.Json; namespace Totem.Runtime.Hosting { /// - /// Configures default values for instances of + /// Configures default values for instances of /// public class JsonFormatOptionsSetup : IConfigureOptions, IPostConfigureOptions { public void Configure(JsonFormatOptions options) { - var settings = options.SerializerSettings; + var settings = options.SerializerOptions; - settings.Formatting = Formatting.Indented; - settings.TypeNameHandling = TypeNameHandling.Auto; + settings.WriteIndented = true; + settings.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + settings.DictionaryKeyPolicy = null; - settings.DateFormatHandling = DateFormatHandling.IsoDateFormat; - settings.DateParseHandling = DateParseHandling.None; - - settings.Converters.AddRange( - new StringEnumConverter(), - new IsoDateTimeConverter { DateTimeFormat = "yyyy-MM-dd HH:mm:ss.fffK" }); + settings.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); } public void PostConfigure(string name, JsonFormatOptions options) { var durableTypes = new DurableTypeSet(options.DurableTypes); - options.SerializerSettings.ContractResolver = new JsonFormatContractResolver(durableTypes); - options.SerializerSettings.SerializationBinder = new JsonFormatSerializationBinder(durableTypes); + options.SerializerOptions.TypeInfoResolver = new TotemJsonTypeInfoResolver(durableTypes); + options.SerializerOptions.Converters.Add(new DurableTypeDiscriminatorConverter(durableTypes)); } } } \ No newline at end of file diff --git a/src/Totem.Runtime/Hosting/JsonServiceExtensions.cs b/src/Totem.Runtime/Hosting/JsonServiceExtensions.cs index 79cc64e8..4db69c32 100644 --- a/src/Totem.Runtime/Hosting/JsonServiceExtensions.cs +++ b/src/Totem.Runtime/Hosting/JsonServiceExtensions.cs @@ -15,7 +15,7 @@ public static IServiceCollection AddJsonFormat(this IServiceCollection services) services .AddOptionsSetup() .AddSingleton(provider => - new JsonFormat(provider.GetOptions().SerializerSettings)); + new JsonFormat(provider.GetOptions().SerializerOptions)); public static IServiceCollection AddJsonFormat(this IServiceCollection services, Action configure) => services.AddJsonFormat().Configure(configure); diff --git a/src/Totem.Runtime/Json/DurableTypeDiscriminatorConverter.cs b/src/Totem.Runtime/Json/DurableTypeDiscriminatorConverter.cs new file mode 100644 index 00000000..882ba2c4 --- /dev/null +++ b/src/Totem.Runtime/Json/DurableTypeDiscriminatorConverter.cs @@ -0,0 +1,142 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Totem.Reflection; + +namespace Totem.Runtime.Json +{ + /// + /// Handles polymorphic type discrimination using the "$type" property with "durable:" prefix values, + /// maintaining backward compatibility with Newtonsoft.Json-serialized data + /// + public class DurableTypeDiscriminatorConverter : JsonConverterFactory + { + readonly IDurableTypeSet _durableTypes; + + public DurableTypeDiscriminatorConverter(IDurableTypeSet durableTypes) + { + _durableTypes = durableTypes; + } + + public override bool CanConvert(Type typeToConvert) => + _durableTypes.TryGetOrAdd(typeToConvert, out _); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => + (JsonConverter)Activator.CreateInstance( + typeof(DurableTypeConverter<>).MakeGenericType(typeToConvert), + _durableTypes); + + sealed class DurableTypeConverter : JsonConverter + { + const string TypePropertyName = "$type"; + const string DurableDiscriminatorPrefix = "durable:"; + + readonly IDurableTypeSet _durableTypes; + + public DurableTypeConverter(IDurableTypeSet durableTypes) + { + _durableTypes = durableTypes; + } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if(reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException($"Expected StartObject, got {reader.TokenType}"); + } + + var readerClone = reader; + Type resolvedType = typeToConvert; + + if(readerClone.Read() && readerClone.TokenType == JsonTokenType.PropertyName) + { + var propName = readerClone.GetString(); + if(propName == TypePropertyName && readerClone.Read() && readerClone.TokenType == JsonTokenType.String) + { + var typeDiscriminator = readerClone.GetString(); + resolvedType = ResolveType(typeDiscriminator) ?? typeToConvert; + } + } + + var newOptions = new JsonSerializerOptions(options); + newOptions.Converters.Remove(this); + + for(int i = newOptions.Converters.Count - 1; i >= 0; i--) + { + if(newOptions.Converters[i] is DurableTypeDiscriminatorConverter) + { + newOptions.Converters.RemoveAt(i); + } + } + + var result = JsonSerializer.Deserialize(ref reader, resolvedType, newOptions); + return (T)result; + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var type = value?.GetType() ?? typeof(T); + + var newOptions = new JsonSerializerOptions(options); + newOptions.Converters.Remove(this); + + for(int i = newOptions.Converters.Count - 1; i >= 0; i--) + { + if(newOptions.Converters[i] is DurableTypeDiscriminatorConverter) + { + newOptions.Converters.RemoveAt(i); + } + } + + if(type != typeof(T) && _durableTypes.TryGetKey(type, out var key)) + { + using var doc = JsonSerializer.SerializeToDocument(value, type, newOptions); + writer.WriteStartObject(); + writer.WriteString(TypePropertyName, $"{DurableDiscriminatorPrefix}{key}"); + + foreach(var prop in doc.RootElement.EnumerateObject()) + { + if(prop.Name != TypePropertyName) + { + prop.WriteTo(writer); + } + } + + writer.WriteEndObject(); + } + else + { + JsonSerializer.Serialize(writer, value, type, newOptions); + } + } + + Type ResolveType(string discriminator) + { + if(discriminator == null) return null; + + if(discriminator.StartsWith(DurableDiscriminatorPrefix)) + { + var keyStr = discriminator.Substring(DurableDiscriminatorPrefix.Length); + if(DurableTypeKey.TryFrom(keyStr, out var parsedKey) && _durableTypes.TryGetByKey(parsedKey, out var type)) + { + return type; + } + } + + // Fallback: try as assembly-qualified name for backward compat + var parts = discriminator.Split(','); + if(parts.Length >= 2) + { + var typeName = parts[0].Trim(); + var assemblyName = parts[1].Trim(); + if(TypeResolver.TryResolve(typeName, assemblyName, out var resolved)) + { + return resolved; + } + } + + return null; + } + } + } +} diff --git a/src/Totem.Runtime/Json/IJsonFormat.cs b/src/Totem.Runtime/Json/IJsonFormat.cs index f0011191..bccdf49f 100644 --- a/src/Totem.Runtime/Json/IJsonFormat.cs +++ b/src/Totem.Runtime/Json/IJsonFormat.cs @@ -1,5 +1,4 @@ -using System; -using Newtonsoft.Json; +using System.Text.Json; namespace Totem.Runtime.Json { @@ -8,8 +7,6 @@ namespace Totem.Runtime.Json /// public interface IJsonFormat { - void Apply(Action operation); - - TResult Apply(Func operation); + JsonSerializerOptions Options { get; } } } \ No newline at end of file diff --git a/src/Totem.Runtime/Json/JsonFormat.cs b/src/Totem.Runtime/Json/JsonFormat.cs index 8ea4120c..460e3cc3 100644 --- a/src/Totem.Runtime/Json/JsonFormat.cs +++ b/src/Totem.Runtime/Json/JsonFormat.cs @@ -1,5 +1,4 @@ -using System; -using Newtonsoft.Json; +using System.Text.Json; namespace Totem.Runtime.Json { @@ -8,17 +7,11 @@ namespace Totem.Runtime.Json /// public sealed class JsonFormat : IJsonFormat { - readonly JsonSerializerSettings _settings = new JsonSerializerSettings(); - - public JsonFormat(JsonSerializerSettings settings) + public JsonFormat(JsonSerializerOptions options) { - _settings = settings; + Options = options; } - public void Apply(Action operation) => - operation(_settings); - - public TResult Apply(Func operation) => - operation(_settings); + public JsonSerializerOptions Options { get; } } } \ No newline at end of file diff --git a/src/Totem.Runtime/Json/JsonFormatExtensions.cs b/src/Totem.Runtime/Json/JsonFormatExtensions.cs index 98b8ee96..98b02979 100644 --- a/src/Totem.Runtime/Json/JsonFormatExtensions.cs +++ b/src/Totem.Runtime/Json/JsonFormatExtensions.cs @@ -1,13 +1,13 @@ using System; using System.IO; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using Totem.IO; namespace Totem.Runtime.Json { /// - /// Formats JSON in a Totem runtime using JSON.NET + /// Formats JSON in a Totem runtime using System.Text.Json /// public static class JsonFormatExtensions { @@ -16,35 +16,39 @@ public static class JsonFormatExtensions // public static string ToJson(this IJsonFormat format, object value) => - format.Apply(settings => JsonConvert.SerializeObject(value, settings)); + JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object), format.Options); public static string ToJson(this IJsonFormat format, object value, Type type) => - format.Apply(settings => JsonConvert.SerializeObject(value, type, settings)); + JsonSerializer.Serialize(value, type, format.Options); - public static JObject ToJObject(this IJsonFormat format, object value) => - format.Apply(settings => JObject.Parse(JsonConvert.SerializeObject(value, settings))); + public static JsonNode ToJsonNode(this IJsonFormat format, object value) => + JsonNode.Parse(JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object), format.Options)); - public static JObject ToJObject(this IJsonFormat format, object value, Type type) => - format.Apply(settings => JObject.Parse(JsonConvert.SerializeObject(value, type, settings))); + public static JsonNode ToJsonNode(this IJsonFormat format, object value, Type type) => + JsonNode.Parse(JsonSerializer.Serialize(value, type, format.Options)); - public static JObject ToJObject(this IJsonFormat format, string json) => - JObject.Parse(json); + public static JsonNode ToJsonNode(this IJsonFormat format, string json) => + JsonNode.Parse(json); // // From // public static T FromJson(this IJsonFormat format, string json) => - format.Apply(settings => JsonConvert.DeserializeObject(json, settings)); + JsonSerializer.Deserialize(json, format.Options); public static object FromJson(this IJsonFormat format, string json) => - format.Apply(settings => JsonConvert.DeserializeObject(json, settings)); + JsonSerializer.Deserialize(json, format.Options); public static object FromJson(this IJsonFormat format, string json, Type type) => - format.Apply(settings => JsonConvert.DeserializeObject(json, type, settings)); + JsonSerializer.Deserialize(json, type, format.Options); - public static void FromJson(this IJsonFormat format, string json, object target) => - format.Apply(settings => JsonConvert.PopulateObject(json, target, settings)); + public static void FromJson(this IJsonFormat format, string json, object target) + { + var type = target.GetType(); + var source = JsonSerializer.Deserialize(json, type, format.Options); + CopyProperties(source, target, type); + } // // To (binary) @@ -56,11 +60,11 @@ public static Binary ToJsonUtf8(this IJsonFormat format, object value) => public static Binary ToJsonUtf8(this IJsonFormat format, object value, Type type) => Binary.FromUtf8(format.ToJson(value, type)); - public static JObject ToJObjectUtf8(this IJsonFormat format, Binary json) => - format.ToJObject(json.ToStringUtf8()); + public static JsonNode ToJsonNodeUtf8(this IJsonFormat format, Binary json) => + format.ToJsonNode(json.ToStringUtf8()); - public static JObject ToJObjectUtf8(this IJsonFormat format, byte[] json) => - format.ToJObjectUtf8(Binary.From(json)); + public static JsonNode ToJsonNodeUtf8(this IJsonFormat format, byte[] json) => + format.ToJsonNodeUtf8(Binary.From(json)); // // From (binary) @@ -101,5 +105,32 @@ public static object FromJsonUtf8(this IJsonFormat format, Stream json, Type typ public static void FromJsonUtf8(this IJsonFormat format, Stream json, object target) => format.FromJsonUtf8(Binary.From(json), target); + + // + // Details + // + + static void CopyProperties(object source, object target, Type type) + { + if(source == null) return; + + const System.Reflection.BindingFlags flags = + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic; + + foreach(var field in type.GetFields(flags)) + { + field.SetValue(target, field.GetValue(source)); + } + + foreach(var prop in type.GetProperties(flags)) + { + if(prop.CanRead && prop.CanWrite) + { + prop.SetValue(target, prop.GetValue(source)); + } + } + } } } \ No newline at end of file diff --git a/src/Totem.Runtime/Json/TotemJsonTypeInfoResolver.cs b/src/Totem.Runtime/Json/TotemJsonTypeInfoResolver.cs new file mode 100644 index 00000000..abba837d --- /dev/null +++ b/src/Totem.Runtime/Json/TotemJsonTypeInfoResolver.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Totem.Reflection; + +namespace Totem.Runtime.Json +{ + /// + /// Resolves JSON type info for Totem's durable type system, replacing + /// JsonFormatContractResolver and JsonFormatSerializationBinder + /// + public class TotemJsonTypeInfoResolver : DefaultJsonTypeInfoResolver + { + readonly IDurableTypeSet _durableTypes; + + public TotemJsonTypeInfoResolver(IDurableTypeSet durableTypes) + { + _durableTypes = durableTypes; + Modifiers.Add(ModifyTypeInfo); + } + + void ModifyTypeInfo(JsonTypeInfo typeInfo) + { + ModifyArrayType(typeInfo); + ModifyObjectType(typeInfo); + } + + void ModifyArrayType(JsonTypeInfo typeInfo) + { + if(typeInfo.Kind != JsonTypeInfoKind.Enumerable) return; + if(!typeof(Many<>).IsAssignableFromGeneric(typeInfo.Type)) return; + + var elementType = typeInfo.Type.GetGenericArguments()[0]; + var callOf = Expression.Call(typeof(Many), "Of", new[] { elementType }); + var lambda = Expression.Lambda>(callOf).Compile(); + + typeInfo.CreateObject = lambda; + } + + void ModifyObjectType(JsonTypeInfo typeInfo) + { + if(typeInfo.Kind != JsonTypeInfoKind.Object) return; + if(!_durableTypes.TryGetOrAdd(typeInfo.Type, out var durableType)) return; + + typeInfo.CreateObject = durableType.Create; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + var durableMembers = Enumerable.Empty() + .Concat(typeInfo.Type.GetFields(flags)) + .Concat(typeInfo.Type.GetProperties(flags)) + .Where(IsDurableProperty) + .ToList(); + + typeInfo.Properties.Clear(); + + foreach(var member in durableMembers) + { + var property = typeInfo.CreateJsonPropertyInfo(GetMemberType(member), GetPropertyName(member, typeInfo.Options)); + + if(member is FieldInfo fieldInfo) + { + property.Get = obj => fieldInfo.GetValue(obj); + property.Set = (obj, val) => fieldInfo.SetValue(obj, val); + } + else if(member is PropertyInfo propInfo) + { + var effectiveProp = member.ReflectedType != member.DeclaringType + ? member.DeclaringType.GetProperty(member.Name, flags) + : propInfo; + + if(effectiveProp == null) continue; + + var getter = effectiveProp.GetGetMethod(nonPublic: true); + var setter = effectiveProp.GetSetMethod(nonPublic: true); + + var isWriteOnly = effectiveProp.IsDefined(typeof(WriteOnlyAttribute), inherit: true); + + if(getter != null) + { + property.Get = obj => effectiveProp.GetValue(obj); + } + + if(!isWriteOnly && setter != null) + { + property.Set = (obj, val) => effectiveProp.SetValue(obj, val); + } + } + + typeInfo.Properties.Add(property); + } + } + + static bool IsDurableProperty(MemberInfo member) => + !member.IsDefined(typeof(TransientAttribute)) + && !member.IsDefined(typeof(CompilerGeneratedAttribute)) + && member.DeclaringType != typeof(Notion); + + static Type GetMemberType(MemberInfo member) => + member is FieldInfo f ? f.FieldType : ((PropertyInfo)member).PropertyType; + + static string GetPropertyName(MemberInfo member, JsonSerializerOptions options) => + options.PropertyNamingPolicy?.ConvertName(member.Name) ?? member.Name; + } +} From 6ffe134b6108aee71edd0fc11cfba52c1bb55c82 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 09:33:09 -0500 Subject: [PATCH 25/30] Phase 3-5: Migrate Timeline converters, EventStore, and MVC layers to STJ Phase 3 - Timeline Converters: - Rewrite FlowKeyConverter as JsonConverter using Utf8JsonReader/Writer - Rewrite TimelinePositionConverter as JsonConverter - Update TimelineJsonFormatOptionsSetup to use SerializerOptions Phase 4 - EventStore Layer: - Rewrite SubscribeCommand: replace JObject/JArray/JToken (Newtonsoft.Json.Linq) with JsonNode/JsonArray (System.Text.Json.Nodes) - Use JsonNode.GetValue() instead of JToken.Value() - Use JsonArray instead of JArray, pattern matching with 'is JsonArray' Phase 5 - MVC Layer: - Rewrite WebRuntimeOptionsSetup: replace IPostConfigureOptions with IPostConfigureOptions, copying STJ settings instead of 22 Newtonsoft properties - Remove .AddNewtonsoftJson() from ConfigureWebApp.cs (STJ is MVC default) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Totem.App.Web/ConfigureWebApp.cs | 1 - .../DbOperations/SubscribeCommand.cs | 26 +++++----- .../Hosting/WebRuntimeOptionsSetup.cs | 48 ++++++------------- .../Hosting/TimelineJsonFormatOptionsSetup.cs | 2 +- src/Totem.Timeline/Json/FlowKeyConverter.cs | 21 ++++---- .../Json/TimelinePositionConverter.cs | 31 +++++------- 6 files changed, 54 insertions(+), 75 deletions(-) diff --git a/src/Totem.App.Web/ConfigureWebApp.cs b/src/Totem.App.Web/ConfigureWebApp.cs index 029ac500..6d96925e 100644 --- a/src/Totem.App.Web/ConfigureWebApp.cs +++ b/src/Totem.App.Web/ConfigureWebApp.cs @@ -280,7 +280,6 @@ public void ApplyAppConfiguration(IWebHostBuilder host) => _mvc.Apply(context, services, () => services .AddMvc(options => options.EnableEndpointRouting = false) - .AddNewtonsoftJson() .AddTotemWebRuntime() .AddCommandsAndQueries() .AddEntryAssemblyPart()); diff --git a/src/Totem.Timeline.EventStore/DbOperations/SubscribeCommand.cs b/src/Totem.Timeline.EventStore/DbOperations/SubscribeCommand.cs index e7a27fac..b4a563d4 100644 --- a/src/Totem.Timeline.EventStore/DbOperations/SubscribeCommand.cs +++ b/src/Totem.Timeline.EventStore/DbOperations/SubscribeCommand.cs @@ -3,7 +3,7 @@ using System.Linq; using System.Threading.Tasks; using EventStore.Client; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using Totem.Reflection; using Totem.Runtime.Json; using Totem.Timeline.Runtime; @@ -55,48 +55,48 @@ ResumeInfo ReadInitialResumeInfo() => async Task ReadResumeInfo(byte[] data) { - var json = _context.Json.ToJObjectUtf8(data); + var json = _context.Json.ToJsonNodeUtf8(data); var checkpoint = ReadCheckpoint(json["checkpoint"]); - var routes = ReadResumeFlows(json["routes"].Value()).ToMany(); - var schedule = await ReadResumeSchedule(json["schedule"].Value()); + var routes = ReadResumeFlows(json["routes"].AsArray()).ToMany(); + var schedule = await ReadResumeSchedule(json["schedule"].AsArray()); var subscription = new TimelineSubscription(_context, checkpoint, _observer); return new ResumeInfo(checkpoint, routes, schedule, subscription); } - TimelinePosition ReadCheckpoint(JToken json) => - json.Type == JTokenType.Null ? TimelinePosition.None : new TimelinePosition(json.Value()); + TimelinePosition ReadCheckpoint(JsonNode json) => + json == null ? TimelinePosition.None : new TimelinePosition(json.GetValue()); - IEnumerable ReadResumeFlows(JArray json) + IEnumerable ReadResumeFlows(JsonArray json) { foreach(var typeItem in json) { - if(typeItem is JArray multiInstance) + if(typeItem is JsonArray multiInstance) { - var type = _context.Area.GetFlow(TypeName.From(multiInstance[0].Value())); + var type = _context.Area.GetFlow(TypeName.From(multiInstance[0].GetValue())); foreach(var idItem in multiInstance.Skip(1)) { - yield return FlowKey.From(type, Id.From(idItem.Value())); + yield return FlowKey.From(type, Id.From(idItem.GetValue())); } } else { - yield return FlowKey.From(typeItem.Value(), _context.Area); + yield return FlowKey.From(typeItem.GetValue(), _context.Area); } } } - async Task> ReadResumeSchedule(JArray json) + async Task> ReadResumeSchedule(JsonArray json) { if(json.Count == 0) { return new Many(); } - var schedule = json.Values().ToMany(); + var schedule = json.Select(node => node.GetValue()).ToMany(); return await new ReadResumeScheduleCommand(_context, schedule).Execute(); } diff --git a/src/Totem.Timeline.Mvc/Hosting/WebRuntimeOptionsSetup.cs b/src/Totem.Timeline.Mvc/Hosting/WebRuntimeOptionsSetup.cs index bca0bd2f..7c156f7f 100644 --- a/src/Totem.Timeline.Mvc/Hosting/WebRuntimeOptionsSetup.cs +++ b/src/Totem.Timeline.Mvc/Hosting/WebRuntimeOptionsSetup.cs @@ -1,14 +1,13 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.NewtonsoftJson; using Microsoft.Extensions.Options; using Totem.Runtime.Hosting; namespace Totem.Timeline.Mvc.Hosting { /// - /// Configures JSON serialization via + /// Configures JSON serialization for MVC to use the Totem JSON format options /// - public class WebRuntimeOptionsSetup : IPostConfigureOptions + public class WebRuntimeOptionsSetup : IPostConfigureOptions { readonly IOptions _jsonFormatOptions; @@ -17,38 +16,21 @@ public WebRuntimeOptionsSetup(IOptions jsonFormatOptions) _jsonFormatOptions = jsonFormatOptions; } - public void PostConfigure(string name, MvcNewtonsoftJsonOptions options) + public void PostConfigure(string name, JsonOptions options) { - var source = _jsonFormatOptions.Value.SerializerSettings; - var target = options.SerializerSettings; + var source = _jsonFormatOptions.Value.SerializerOptions; - target.Context = source.Context; - target.Culture = source.Culture; - target.ContractResolver = source.ContractResolver; - target.ConstructorHandling = source.ConstructorHandling; - target.Converters = source.Converters; - target.CheckAdditionalContent = source.CheckAdditionalContent; - target.DateFormatHandling = source.DateFormatHandling; - target.DateFormatString = source.DateFormatString; - target.DateParseHandling = source.DateParseHandling; - target.DateTimeZoneHandling = source.DateTimeZoneHandling; - target.DefaultValueHandling = source.DefaultValueHandling; - target.EqualityComparer = source.EqualityComparer; - target.FloatFormatHandling = source.FloatFormatHandling; - target.Formatting = source.Formatting; - target.FloatParseHandling = source.FloatParseHandling; - target.MaxDepth = source.MaxDepth; - target.MetadataPropertyHandling = source.MetadataPropertyHandling; - target.MissingMemberHandling = source.MissingMemberHandling; - target.NullValueHandling = source.NullValueHandling; - target.ObjectCreationHandling = source.ObjectCreationHandling; - target.PreserveReferencesHandling = source.PreserveReferencesHandling; - target.ReferenceLoopHandling = source.ReferenceLoopHandling; - target.SerializationBinder = source.SerializationBinder; - target.StringEscapeHandling = source.StringEscapeHandling; - target.TraceWriter = source.TraceWriter; - target.TypeNameHandling = source.TypeNameHandling; - target.TypeNameAssemblyFormatHandling = source.TypeNameAssemblyFormatHandling; + options.JsonSerializerOptions.WriteIndented = source.WriteIndented; + options.JsonSerializerOptions.PropertyNamingPolicy = source.PropertyNamingPolicy; + options.JsonSerializerOptions.DictionaryKeyPolicy = source.DictionaryKeyPolicy; + options.JsonSerializerOptions.DefaultIgnoreCondition = source.DefaultIgnoreCondition; + options.JsonSerializerOptions.TypeInfoResolver = source.TypeInfoResolver; + + options.JsonSerializerOptions.Converters.Clear(); + foreach(var converter in source.Converters) + { + options.JsonSerializerOptions.Converters.Add(converter); + } } } } diff --git a/src/Totem.Timeline/Hosting/TimelineJsonFormatOptionsSetup.cs b/src/Totem.Timeline/Hosting/TimelineJsonFormatOptionsSetup.cs index f9d6fafb..a259dd8d 100644 --- a/src/Totem.Timeline/Hosting/TimelineJsonFormatOptionsSetup.cs +++ b/src/Totem.Timeline/Hosting/TimelineJsonFormatOptionsSetup.cs @@ -25,7 +25,7 @@ public void Configure(JsonFormatOptions options) } void AddConverters(JsonFormatOptions options) => - options.SerializerSettings.Converters.AddRange( + options.SerializerOptions.Converters.AddRange( new FlowKeyConverter(_area), new TimelinePositionConverter()); diff --git a/src/Totem.Timeline/Json/FlowKeyConverter.cs b/src/Totem.Timeline/Json/FlowKeyConverter.cs index a5b15358..60860013 100644 --- a/src/Totem.Timeline/Json/FlowKeyConverter.cs +++ b/src/Totem.Timeline/Json/FlowKeyConverter.cs @@ -1,5 +1,6 @@ using System; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; using Totem.Timeline.Area; namespace Totem.Timeline.Json @@ -7,7 +8,7 @@ namespace Totem.Timeline.Json /// /// Converts instances of to and from JSON /// - public class FlowKeyConverter : JsonConverter + public class FlowKeyConverter : JsonConverter { readonly AreaMap _area; @@ -16,13 +17,15 @@ public FlowKeyConverter(AreaMap area) _area = area; } - public override bool CanConvert(Type objectType) => - objectType == typeof(FlowKey); + public override FlowKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null ? null : FlowKey.From(reader.GetString(), _area); - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) => - reader.Value == null ? null : FlowKey.From(reader.Value.ToString(), _area); - - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => - writer.WriteValue(value?.ToString()); + public override void Write(Utf8JsonWriter writer, FlowKey value, JsonSerializerOptions options) + { + if(value == null) + writer.WriteNullValue(); + else + writer.WriteStringValue(value.ToString()); + } } } \ No newline at end of file diff --git a/src/Totem.Timeline/Json/TimelinePositionConverter.cs b/src/Totem.Timeline/Json/TimelinePositionConverter.cs index 723d7948..c71ef916 100644 --- a/src/Totem.Timeline/Json/TimelinePositionConverter.cs +++ b/src/Totem.Timeline/Json/TimelinePositionConverter.cs @@ -1,5 +1,6 @@ using System; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Totem.Timeline.Json { @@ -11,28 +12,22 @@ namespace Totem.Timeline.Json /// making this a lossy conversion. However, we will have more immediate issues if we /// ever see timeline positions at that scale. /// - public class TimelinePositionConverter : JsonConverter + public class TimelinePositionConverter : JsonConverter { - public override bool CanConvert(Type objectType) => - objectType == typeof(TimelinePosition); + public override TimelinePosition Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if(reader.TokenType == JsonTokenType.Null) + return TimelinePosition.None; - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) => - reader.Value == null - ? TimelinePosition.None - : new TimelinePosition(Convert.ToInt64(reader.Value)); + return new TimelinePosition(reader.GetInt64()); + } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, TimelinePosition value, JsonSerializerOptions options) { - var position = (TimelinePosition) value; - - if(position.IsNone) - { - writer.WriteNull(); - } + if(value.IsNone) + writer.WriteNullValue(); else - { - writer.WriteValue(position.ToInt64()); - } + writer.WriteNumberValue(value.ToInt64()); } } } \ No newline at end of file From 44cfe16737e1730917c08c515ce524a5ed94f72f Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 09:34:27 -0500 Subject: [PATCH 26/30] Phase 6: Remove Newtonsoft.Json package dependencies and delete old files - Remove Newtonsoft.Json 13.0.3 package from Totem.Runtime.csproj - Remove Microsoft.AspNetCore.Mvc.NewtonsoftJson 10.0.0 package from Totem.Timeline.Mvc.csproj - Delete JsonFormatContractResolver.cs (replaced by TotemJsonTypeInfoResolver) - Delete JsonFormatSerializationBinder.cs (merged into DurableTypeDiscriminatorConverter) All direct Newtonsoft.Json dependencies are now removed. The only remaining Newtonsoft reference is a transitive dependency from EventStore.Client.Grpc (Newtonsoft.Json 9.0.1) which is outside our control. Solution builds with 0 errors across all projects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/JsonFormatContractResolver.cs | 112 ------------------ .../Json/JsonFormatSerializationBinder.cs | 49 -------- src/Totem.Runtime/Totem.Runtime.csproj | 2 +- .../Totem.Timeline.Mvc.csproj | 2 +- 4 files changed, 2 insertions(+), 163 deletions(-) delete mode 100644 src/Totem.Runtime/Json/JsonFormatContractResolver.cs delete mode 100644 src/Totem.Runtime/Json/JsonFormatSerializationBinder.cs diff --git a/src/Totem.Runtime/Json/JsonFormatContractResolver.cs b/src/Totem.Runtime/Json/JsonFormatContractResolver.cs deleted file mode 100644 index c75eee09..00000000 --- a/src/Totem.Runtime/Json/JsonFormatContractResolver.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Runtime.CompilerServices; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using Totem.Reflection; - -namespace Totem.Runtime.Json -{ - /// - /// Resolves contracts for serializing and deserializing objects in a Totem runtime - /// - public class JsonFormatContractResolver : DefaultContractResolver - { - readonly IDurableTypeSet _durableTypes; - - public JsonFormatContractResolver(IDurableTypeSet durableTypes) - { - _durableTypes = durableTypes; - - NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = false }; - } - - protected override JsonArrayContract CreateArrayContract(Type objectType) - { - var contract = base.CreateArrayContract(objectType); - - if(typeof(Many<>).IsAssignableFromGeneric(objectType)) - { - var callOf = Expression.Call(typeof(Many), "Of", new[] { contract.CollectionItemType }); - - var lambda = Expression.Lambda>(callOf); - - contract.DefaultCreator = lambda.Compile(); - } - - return contract; - } - - protected override JsonObjectContract CreateObjectContract(Type objectType) - { - var contract = base.CreateObjectContract(objectType); - - if(_durableTypes.TryGetOrAdd(objectType, out var durableType)) - { - contract.DefaultCreator = durableType.Create; - } - - return contract; - } - - protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) - { - if(!_durableTypes.TryGetOrAdd(type, out _)) - { - return base.CreateProperties(type, memberSerialization); - } - - const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - - return Enumerable.Empty() - .Concat(type.GetFields(flags)) - .Concat(type.GetProperties(flags)) - .Where(IsDurableProperty) - .Select(member => CreateProperty(member, memberSerialization)) - .ToList(); - } - - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) => - !IsDurableProperty(member) ? null : CreateDurableProperty(member, memberSerialization); - - static bool IsDurableProperty(MemberInfo member) => - !member.IsDefined(typeof(TransientAttribute)) - && !member.IsDefined(typeof(CompilerGeneratedAttribute)) - && member.DeclaringType != typeof(Notion); - - JsonProperty CreateDurableProperty(MemberInfo member, MemberSerialization memberSerialization) - { - var property = base.CreateProperty(member, memberSerialization); - - if(member is FieldInfo) - { - property.Writable = true; - property.Readable = true; - } - else if(member.ReflectedType != member.DeclaringType) - { - property = CreateProperty(member.DeclaringType.GetProperty(member.Name), memberSerialization); - } - else - { - var info = (PropertyInfo) member; - - var canSet = info.CanWrite || info.GetSetMethod(nonPublic: true) != null; - var canGet = info.CanRead || info.GetGetMethod(nonPublic: true) != null; - - var isWriteOnly = property - .AttributeProvider - .GetAttributes(typeof(WriteOnlyAttribute), inherit: true) - .Any(); - - property.Writable = !isWriteOnly && canSet; - property.Readable = canGet; - } - - return property; - } - } -} \ No newline at end of file diff --git a/src/Totem.Runtime/Json/JsonFormatSerializationBinder.cs b/src/Totem.Runtime/Json/JsonFormatSerializationBinder.cs deleted file mode 100644 index 94067464..00000000 --- a/src/Totem.Runtime/Json/JsonFormatSerializationBinder.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using Newtonsoft.Json.Serialization; -using Totem.Reflection; - -namespace Totem.Runtime.Json -{ - /// - /// Binds durable types to their area keys - /// - public class JsonFormatSerializationBinder : DefaultSerializationBinder - { - const string _prefix = "durable:"; - - readonly IDurableTypeSet _durableTypes; - - public JsonFormatSerializationBinder(IDurableTypeSet durableTypes) - { - _durableTypes = durableTypes; - } - - public override void BindToName(Type serializedType, out string assemblyName, out string typeName) - { - if(_durableTypes.TryGetKey(serializedType, out var key)) - { - assemblyName = null; - typeName = $"{_prefix}{key}"; - } - else - { - base.BindToName(serializedType, out assemblyName, out typeName); - } - } - - public override Type BindToType(string assemblyName, string typeName) - { - if(typeName.StartsWith(_prefix)) - { - var key = typeName.Substring(_prefix.Length); - - if(DurableTypeKey.TryFrom(key, out var parsedKey) && _durableTypes.TryGetByKey(parsedKey, out var type)) - { - return type; - } - } - - return TypeResolver.Resolve(typeName, assemblyName); - } - } -} \ No newline at end of file diff --git a/src/Totem.Runtime/Totem.Runtime.csproj b/src/Totem.Runtime/Totem.Runtime.csproj index b28b4aca..92940d11 100644 --- a/src/Totem.Runtime/Totem.Runtime.csproj +++ b/src/Totem.Runtime/Totem.Runtime.csproj @@ -27,7 +27,7 @@ - + diff --git a/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj b/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj index e734350f..f1b7bb07 100644 --- a/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj +++ b/src/Totem.Timeline.Mvc/Totem.Timeline.Mvc.csproj @@ -24,7 +24,7 @@ - + From f4e6ab4d59447efca96026f2373e1ef239776ff8 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 09:38:18 -0500 Subject: [PATCH 27/30] Fix: Scan all JSON properties for \ discriminator, not just first The DurableTypeDiscriminatorConverter.Read method was only checking the first property for the \ discriminator. Since JSON property order is not guaranteed, this could miss the discriminator and deserialize as the base type instead of the actual polymorphic type. Now scans through all properties using Utf8JsonReader.TrySkip() to find \ regardless of position in the JSON object. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/DurableTypeDiscriminatorConverter.cs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/Totem.Runtime/Json/DurableTypeDiscriminatorConverter.cs b/src/Totem.Runtime/Json/DurableTypeDiscriminatorConverter.cs index 882ba2c4..05697ad6 100644 --- a/src/Totem.Runtime/Json/DurableTypeDiscriminatorConverter.cs +++ b/src/Totem.Runtime/Json/DurableTypeDiscriminatorConverter.cs @@ -45,16 +45,29 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial throw new JsonException($"Expected StartObject, got {reader.TokenType}"); } + // Scan all properties to find $type (JSON property order is not guaranteed) var readerClone = reader; Type resolvedType = typeToConvert; - if(readerClone.Read() && readerClone.TokenType == JsonTokenType.PropertyName) + while(readerClone.Read()) { - var propName = readerClone.GetString(); - if(propName == TypePropertyName && readerClone.Read() && readerClone.TokenType == JsonTokenType.String) + if(readerClone.TokenType == JsonTokenType.EndObject) break; + + if(readerClone.TokenType == JsonTokenType.PropertyName) { - var typeDiscriminator = readerClone.GetString(); - resolvedType = ResolveType(typeDiscriminator) ?? typeToConvert; + var propName = readerClone.GetString(); + if(propName == TypePropertyName) + { + if(readerClone.Read() && readerClone.TokenType == JsonTokenType.String) + { + resolvedType = ResolveType(readerClone.GetString()) ?? typeToConvert; + } + break; + } + + // Skip the value of non-$type properties + readerClone.Read(); + readerClone.TrySkip(); } } From 9e00c5a744e4ef889cd3e44d6d70281899553b20 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 10:48:01 -0500 Subject: [PATCH 28/30] Add TypeConverterJsonConverterFactory to bridge TypeConverter types to STJ STJ does not auto-detect [TypeConverter] attributes like Newtonsoft did. This caused deserialization crashes for 20+ types (TypeName, Id, FileLink, etc.) that use TextConverter for string-based serialization. The factory detects types with a non-default TypeConverter that supports string conversion, and serializes/deserializes them as JSON strings via ConvertFromInvariantString/ConvertToInvariantString. Registered in JsonFormatOptionsSetup.Configure() after JsonStringEnumConverter, before the durable type converters in PostConfigure(). No conflicts since TypeConverter types are not durable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Hosting/JsonFormatOptionsSetup.cs | 1 + .../Json/TypeConverterJsonConverterFactory.cs | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs diff --git a/src/Totem.Runtime/Hosting/JsonFormatOptionsSetup.cs b/src/Totem.Runtime/Hosting/JsonFormatOptionsSetup.cs index 5286fd99..44a1bbaf 100644 --- a/src/Totem.Runtime/Hosting/JsonFormatOptionsSetup.cs +++ b/src/Totem.Runtime/Hosting/JsonFormatOptionsSetup.cs @@ -19,6 +19,7 @@ public void Configure(JsonFormatOptions options) settings.DictionaryKeyPolicy = null; settings.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + settings.Converters.Add(new TypeConverterJsonConverterFactory()); } public void PostConfigure(string name, JsonFormatOptions options) diff --git a/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs b/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs new file mode 100644 index 00000000..31b72cb5 --- /dev/null +++ b/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs @@ -0,0 +1,58 @@ +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Totem.Runtime.Json +{ + /// + /// Bridges to STJ, replicating + /// Newtonsoft's built-in behavior of serializing [TypeConverter] types as JSON strings + /// + public class TypeConverterJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + var converter = TypeDescriptor.GetConverter(typeToConvert); + + return converter.GetType() != typeof(TypeConverter) + && converter.CanConvertFrom(typeof(string)) + && converter.CanConvertTo(typeof(string)); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var converterType = typeof(TypeConverterJsonConverter<>).MakeGenericType(typeToConvert); + + return (JsonConverter)Activator.CreateInstance(converterType); + } + + class TypeConverterJsonConverter : JsonConverter + { + readonly TypeConverter _converter = TypeDescriptor.GetConverter(typeof(T)); + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if(reader.TokenType == JsonTokenType.Null) + { + return default; + } + + var text = reader.GetString(); + + return (T)_converter.ConvertFromInvariantString(text); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + if(value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(_converter.ConvertToInvariantString(value)); + } + } + } +} From 6d2abf51a13df16dff82266d6d01a626d17fb78d Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 11:39:32 -0500 Subject: [PATCH 29/30] Narrow TypeConverterJsonConverterFactory.CanConvert to TextConverter only The previous check matched all .NET built-in types with TypeConverters (int, DateTime, bool, Guid, etc.), causing reader.GetString() to throw on non-string JSON tokens. This silently broke ASP.NET model binding for any model with non-string properties (e.g. SmartScanRecord.FileCount). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Json/TypeConverterJsonConverterFactory.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs b/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs index 31b72cb5..058ee2e3 100644 --- a/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs +++ b/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Text.Json; using System.Text.Json.Serialization; +using Totem.IO; namespace Totem.Runtime.Json { @@ -13,11 +14,7 @@ public class TypeConverterJsonConverterFactory : JsonConverterFactory { public override bool CanConvert(Type typeToConvert) { - var converter = TypeDescriptor.GetConverter(typeToConvert); - - return converter.GetType() != typeof(TypeConverter) - && converter.CanConvertFrom(typeof(string)) - && converter.CanConvertTo(typeof(string)); + return TypeDescriptor.GetConverter(typeToConvert) is TextConverter; } public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) From cc0de28207e9e3cf748037d903f16fe7b62f5a66 Mon Sep 17 00:00:00 2001 From: Alexander Johnston Date: Tue, 17 Feb 2026 11:56:53 -0500 Subject: [PATCH 30/30] Fix dictionary keys when writing ID through custom text converter. --- .../Json/TypeConverterJsonConverterFactory.cs | 14 ++++++++++++++ src/Totem.Timeline/Runtime/TimelineHost.cs | 2 -- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs b/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs index 058ee2e3..b74dc189 100644 --- a/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs +++ b/src/Totem.Runtime/Json/TypeConverterJsonConverterFactory.cs @@ -50,6 +50,20 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions writer.WriteStringValue(_converter.ConvertToInvariantString(value)); } + + // Necessary to read Totem.Id as Dictionary Keys. + public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var text = reader.GetString(); + + return (T)_converter.ConvertFromInvariantString(text); + } + + // Necessary to save Totem.Id as Dictionary Keys. + public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WritePropertyName(_converter.ConvertToInvariantString(value)); + } } } } diff --git a/src/Totem.Timeline/Runtime/TimelineHost.cs b/src/Totem.Timeline/Runtime/TimelineHost.cs index a50faada..917cd3a0 100644 --- a/src/Totem.Timeline/Runtime/TimelineHost.cs +++ b/src/Totem.Timeline/Runtime/TimelineHost.cs @@ -62,8 +62,6 @@ async Task ResumeSubscription() _schedule.Resume(resumeInfo.Schedule); - Log.Info("Resume: yes"); - return resumeInfo.Subscription; }