From 35cef9722483f60e215f4ded5ae41711ee0a2394 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 22 Jul 2026 16:27:51 +0700 Subject: [PATCH 1/4] Add ConfigureJsonOptions and RegisteredChangeType to CrdtConfig. Let clients register JSON customizations before options freeze, and expose change type discriminators alongside CLR types. Co-authored-by: Cursor --- src/SIL.Harmony.Tests/ConfigTests.cs | 41 ++++++++++++++++++++++++- src/SIL.Harmony/CrdtConfig.cs | 45 +++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/SIL.Harmony.Tests/ConfigTests.cs b/src/SIL.Harmony.Tests/ConfigTests.cs index e8ca40d..f54f2b8 100644 --- a/src/SIL.Harmony.Tests/ConfigTests.cs +++ b/src/SIL.Harmony.Tests/ConfigTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using SIL.Harmony.Changes; using SIL.Harmony.Sample.Changes; using SIL.Harmony.Sample.Models; using SIL.Harmony.Tests.Adapter; @@ -26,7 +28,44 @@ public void CanGetChangeTypes() var config = new CrdtConfig(); config.ChangeTypeListBuilder.Add(); config.ChangeTypeListBuilder.Add(); + config.ChangeTypeListBuilder.Add>(); var types = config.ChangeTypes.ToArray(); - types.Should().BeEquivalentTo([typeof(NewDefinitionChange), typeof(SetWordTextChange)]); + types.Should().BeEquivalentTo([ + new RegisteredChangeType(typeof(NewDefinitionChange), nameof(NewDefinitionChange)), + new RegisteredChangeType(typeof(SetWordTextChange), nameof(SetWordTextChange)), + new RegisteredChangeType(typeof(DeleteChange), "delete:Word"), + ]); + } + + [Fact] + public void ConfigureJsonOptions_applies_callback() + { + var config = new CrdtConfig(); + config.ConfigureJsonOptions(o => o.PropertyNamingPolicy = JsonNamingPolicy.CamelCase); + + config.JsonSerializerOptions.PropertyNamingPolicy.Should().Be(JsonNamingPolicy.CamelCase); + } + + [Fact] + public void ConfigureJsonOptions_composes_multiple_callbacks() + { + var config = new CrdtConfig(); + config.ConfigureJsonOptions(o => o.PropertyNamingPolicy = JsonNamingPolicy.CamelCase); + config.ConfigureJsonOptions(o => o.WriteIndented = true); + + config.JsonSerializerOptions.PropertyNamingPolicy.Should().Be(JsonNamingPolicy.CamelCase); + config.JsonSerializerOptions.WriteIndented.Should().BeTrue(); + } + + [Fact] + public void ConfigureJsonOptions_throws_after_freeze() + { + var config = new CrdtConfig(); + _ = config.JsonSerializerOptions; + + var act = () => config.ConfigureJsonOptions(o => o.WriteIndented = true); + + act.Should().Throw() + .WithMessage("*JsonOptionsBuilder* frozen*"); } } diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/CrdtConfig.cs index 01d9d13..c17e230 100644 --- a/src/SIL.Harmony/CrdtConfig.cs +++ b/src/SIL.Harmony/CrdtConfig.cs @@ -11,6 +11,8 @@ namespace SIL.Harmony; public delegate ValueTask BeforeSaveObjectDelegate(object obj, ObjectSnapshot snapshot); +public readonly record struct RegisteredChangeType(Type Type, string Discriminator); + public class CrdtConfig { /// @@ -24,10 +26,14 @@ public class CrdtConfig /// public bool AlwaysValidateCommits { get; set; } = true; public ChangeTypeListBuilder ChangeTypeListBuilder { get; } = new(); - public IEnumerable ChangeTypes => ChangeTypeListBuilder.Types.Select(t => t.DerivedType); + public IEnumerable ChangeTypes => + ChangeTypeListBuilder.Types.Select(t => new RegisteredChangeType( + t.DerivedType, + (string)t.TypeDiscriminator!)); public ObjectTypeListBuilder ObjectTypeListBuilder { get; } = new(); public IEnumerable ObjectTypes => ObjectTypeListBuilder.AdapterProviders.SelectMany(p => p.GetRegistrations().Select(r => r.ObjectDbType)); public JsonSerializerOptions JsonSerializerOptions => _lazyJsonSerializerOptions.Value; + private readonly JsonOptionsBuilder _jsonOptionsBuilder = new(); private readonly Lazy _lazyJsonSerializerOptions; private readonly Lazy _lazyChangeDiscriminatorMaps; @@ -46,9 +52,20 @@ private JsonSerializerOptions CreateJsonSerializerOptions() TypeInfoResolver = MakeJsonTypeResolver() }; options.Converters.Add(new PeekThenConcreteChangeConverter(changeDiscriminators.ByDiscriminator)); + _jsonOptionsBuilder.ApplyTo(options); return options; } + /// + /// Registers a callback to customize before they are frozen. + /// Callbacks run after Harmony's type resolver and change converter are configured. + /// Replacing or removing the change converter will break serialization. + /// + public void ConfigureJsonOptions(Action configure) + { + _jsonOptionsBuilder.Configure(configure); + } + private ChangeDiscriminatorMaps BuildChangeDiscriminatorMaps() { ChangeTypeListBuilder.Freeze(); @@ -157,6 +174,32 @@ public void AddRemoteResourceEntity(string? cachePath = null) } } +internal class JsonOptionsBuilder +{ + private bool _frozen; + private readonly List> _configurations = []; + + public void Configure(Action configure) + { + CheckFrozen(); + _configurations.Add(configure); + } + + internal void ApplyTo(JsonSerializerOptions options) + { + foreach (var configure in _configurations) + configure(options); + Freeze(); + } + + private void Freeze() => _frozen = true; + + private void CheckFrozen() + { + if (_frozen) throw new InvalidOperationException($"{nameof(JsonOptionsBuilder)} is frozen"); + } +} + public class ChangeTypeListBuilder { private bool _frozen; From 7cba591573470a49932f98eae749cca67dccdc42 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 22 Jul 2026 16:41:43 +0700 Subject: [PATCH 2/4] Store registered change types in ChangeTypeListBuilder as read-only. Replace JsonDerivedType with RegisteredChangeType and expose Types via IReadOnlyList to prevent external mutation. Co-authored-by: Cursor --- src/SIL.Harmony/CrdtConfig.cs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/CrdtConfig.cs index c17e230..4ccf11b 100644 --- a/src/SIL.Harmony/CrdtConfig.cs +++ b/src/SIL.Harmony/CrdtConfig.cs @@ -26,10 +26,7 @@ public class CrdtConfig /// public bool AlwaysValidateCommits { get; set; } = true; public ChangeTypeListBuilder ChangeTypeListBuilder { get; } = new(); - public IEnumerable ChangeTypes => - ChangeTypeListBuilder.Types.Select(t => new RegisteredChangeType( - t.DerivedType, - (string)t.TypeDiscriminator!)); + public IReadOnlyList ChangeTypes => ChangeTypeListBuilder.Types; public ObjectTypeListBuilder ObjectTypeListBuilder { get; } = new(); public IEnumerable ObjectTypes => ObjectTypeListBuilder.AdapterProviders.SelectMany(p => p.GetRegistrations().Select(r => r.ObjectDbType)); public JsonSerializerOptions JsonSerializerOptions => _lazyJsonSerializerOptions.Value; @@ -72,14 +69,10 @@ private ChangeDiscriminatorMaps BuildChangeDiscriminatorMaps() var knownChanges = new Dictionary(ChangeTypeListBuilder.Types.Count); var discriminators = new Dictionary(ChangeTypeListBuilder.Types.Count); - foreach (var derived in ChangeTypeListBuilder.Types) + foreach (var changeType in ChangeTypeListBuilder.Types) { - if (derived.TypeDiscriminator is not string discriminator) - throw new InvalidOperationException( - $"Change type {derived.DerivedType} must use a string $type discriminator"); - - knownChanges.Add(discriminator, derived.DerivedType); - discriminators.Add(derived.DerivedType, discriminator); + knownChanges.Add(changeType.Discriminator, changeType.Type); + discriminators.Add(changeType.Type, changeType.Discriminator); } return new ChangeDiscriminatorMaps(knownChanges, discriminators); @@ -216,13 +209,14 @@ private void CheckFrozen() { if (_frozen) throw new InvalidOperationException($"{nameof(ChangeTypeListBuilder)} is frozen"); } - internal List Types { get; } = []; + private readonly List _types = []; + public IReadOnlyList Types => _types.AsReadOnly(); public ChangeTypeListBuilder Add() where TDerived : IChange, IPolyType { CheckFrozen(); - if (Types.Any(t => t.DerivedType == typeof(TDerived))) return this; - Types.Add(new JsonDerivedType(typeof(TDerived), TDerived.TypeName)); + if (_types.Any(t => t.Type == typeof(TDerived))) return this; + _types.Add(new RegisteredChangeType(typeof(TDerived), TDerived.TypeName)); return this; } } From 0395eb842fe7a2da999f90abddb162a32a16918c Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 22 Jul 2026 16:54:18 +0700 Subject: [PATCH 3/4] Move config to Config folder and rename CrdtConfig to HarmonyConfig. Split builders into separate files under SIL.Harmony.Config and update all references. Co-authored-by: Cursor --- src/SIL.Harmony.Sample/SampleDbContext.cs | 3 +- .../Adapter/CustomObjectAdapterTests.cs | 3 +- src/SIL.Harmony.Tests/ConfigTests.cs | 11 +- src/SIL.Harmony.Tests/DataModelTestBase.cs | 3 +- .../PersistExtraDataTests.cs | 3 +- .../Adapters/CustomAdapterProvider.cs | 1 + .../Adapters/DefaultAdapterProvider.cs | 1 + src/SIL.Harmony/Changes/Change.cs | 2 +- src/SIL.Harmony/Changes/ChangeContext.cs | 5 +- .../PeekThenConcreteChangeConverter.cs | 2 +- .../Config/ChangeTypeListBuilder.cs | 35 ++++ .../HarmonyConfig.cs} | 151 +----------------- src/SIL.Harmony/Config/JsonOptionsBuilder.cs | 29 ++++ .../Config/ObjectTypeListBuilder.cs | 93 +++++++++++ src/SIL.Harmony/CrdtKernel.cs | 19 +-- src/SIL.Harmony/DataModel.cs | 5 +- .../Db/CrdtDbContextOptionsExtensions.cs | 3 +- src/SIL.Harmony/Db/CrdtRepository.cs | 5 +- src/SIL.Harmony/ResourceService.cs | 5 +- src/SIL.Harmony/SnapshotWorker.cs | 9 +- 20 files changed, 207 insertions(+), 181 deletions(-) create mode 100644 src/SIL.Harmony/Config/ChangeTypeListBuilder.cs rename src/SIL.Harmony/{CrdtConfig.cs => Config/HarmonyConfig.cs} (57%) create mode 100644 src/SIL.Harmony/Config/JsonOptionsBuilder.cs create mode 100644 src/SIL.Harmony/Config/ObjectTypeListBuilder.cs diff --git a/src/SIL.Harmony.Sample/SampleDbContext.cs b/src/SIL.Harmony.Sample/SampleDbContext.cs index 4523ec9..c6d1425 100644 --- a/src/SIL.Harmony.Sample/SampleDbContext.cs +++ b/src/SIL.Harmony.Sample/SampleDbContext.cs @@ -1,11 +1,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Db; namespace SIL.Harmony.Sample; -public class SampleDbContext(DbContextOptions options, IOptions crdtConfig) : DbContext(options), ICrdtDbContext +public class SampleDbContext(DbContextOptions options, IOptions crdtConfig) : DbContext(options), ICrdtDbContext { protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs index 0ecea1c..575248c 100644 --- a/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs +++ b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SIL.Harmony.Adapters; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Db; using SIL.Harmony.Entities; @@ -11,7 +12,7 @@ namespace SIL.Harmony.Tests.Adapter; public class CustomObjectAdapterTests { - public class MyDbContext(DbContextOptions options, IOptions crdtConfig) + public class MyDbContext(DbContextOptions options, IOptions crdtConfig) : DbContext(options), ICrdtDbContext { public DbSet MyClasses { get; set; } = null!; diff --git a/src/SIL.Harmony.Tests/ConfigTests.cs b/src/SIL.Harmony.Tests/ConfigTests.cs index f54f2b8..d9608b7 100644 --- a/src/SIL.Harmony.Tests/ConfigTests.cs +++ b/src/SIL.Harmony.Tests/ConfigTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Sample.Changes; using SIL.Harmony.Sample.Models; using SIL.Harmony.Tests.Adapter; @@ -11,7 +12,7 @@ public class ConfigTests [Fact] public void CanGetEntityTypes() { - var config = new CrdtConfig(); + var config = new HarmonyConfig(); config.ObjectTypeListBuilder.DefaultAdapter() .Add() .Add(); @@ -25,7 +26,7 @@ public void CanGetEntityTypes() [Fact] public void CanGetChangeTypes() { - var config = new CrdtConfig(); + var config = new HarmonyConfig(); config.ChangeTypeListBuilder.Add(); config.ChangeTypeListBuilder.Add(); config.ChangeTypeListBuilder.Add>(); @@ -40,7 +41,7 @@ public void CanGetChangeTypes() [Fact] public void ConfigureJsonOptions_applies_callback() { - var config = new CrdtConfig(); + var config = new HarmonyConfig(); config.ConfigureJsonOptions(o => o.PropertyNamingPolicy = JsonNamingPolicy.CamelCase); config.JsonSerializerOptions.PropertyNamingPolicy.Should().Be(JsonNamingPolicy.CamelCase); @@ -49,7 +50,7 @@ public void ConfigureJsonOptions_applies_callback() [Fact] public void ConfigureJsonOptions_composes_multiple_callbacks() { - var config = new CrdtConfig(); + var config = new HarmonyConfig(); config.ConfigureJsonOptions(o => o.PropertyNamingPolicy = JsonNamingPolicy.CamelCase); config.ConfigureJsonOptions(o => o.WriteIndented = true); @@ -60,7 +61,7 @@ public void ConfigureJsonOptions_composes_multiple_callbacks() [Fact] public void ConfigureJsonOptions_throws_after_freeze() { - var config = new CrdtConfig(); + var config = new HarmonyConfig(); _ = config.JsonSerializerOptions; var act = () => config.ConfigureJsonOptions(o => o.WriteIndented = true); diff --git a/src/SIL.Harmony.Tests/DataModelTestBase.cs b/src/SIL.Harmony.Tests/DataModelTestBase.cs index 756162d..8dcf44f 100644 --- a/src/SIL.Harmony.Tests/DataModelTestBase.cs +++ b/src/SIL.Harmony.Tests/DataModelTestBase.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Db; using SIL.Harmony.Sample; using SIL.Harmony.Sample.Changes; @@ -38,7 +39,7 @@ public DataModelTestBase(SqliteConnection connection, bool alwaysValidate = true { builder.UseSqlite(connection, true); }, performanceTest) - .Configure(config => config.AlwaysValidateCommits = alwaysValidate) + .Configure(config => config.AlwaysValidateCommits = alwaysValidate) .Replace(ServiceDescriptor.Singleton(MockTimeProvider)); configure?.Invoke(serviceCollection); _services = serviceCollection.BuildServiceProvider(); diff --git a/src/SIL.Harmony.Tests/PersistExtraDataTests.cs b/src/SIL.Harmony.Tests/PersistExtraDataTests.cs index 9eb6993..22e8857 100644 --- a/src/SIL.Harmony.Tests/PersistExtraDataTests.cs +++ b/src/SIL.Harmony.Tests/PersistExtraDataTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Entities; namespace SIL.Harmony.Tests; @@ -53,7 +54,7 @@ public PersistExtraDataTests() { _dataModelTestBase = new DataModelTestBase(configure: services => { - services.Configure(config => + services.Configure(config => { config.ObjectTypeListBuilder.DefaultAdapter().Add(); config.ChangeTypeListBuilder.Add(); diff --git a/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs index 978bb94..261c3d3 100644 --- a/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs +++ b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Config; using SIL.Harmony.Entities; using SIL.Harmony.Helpers; diff --git a/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs b/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs index 420019e..5a1ef54 100644 --- a/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs +++ b/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Config; using SIL.Harmony.Entities; using SIL.Harmony.Helpers; diff --git a/src/SIL.Harmony/Changes/Change.cs b/src/SIL.Harmony/Changes/Change.cs index 8367b89..c816403 100644 --- a/src/SIL.Harmony/Changes/Change.cs +++ b/src/SIL.Harmony/Changes/Change.cs @@ -5,7 +5,7 @@ namespace SIL.Harmony.Changes; /// /// Polymorphic JSON for is owned by -/// PeekThenConcreteChangeConverter (via ), not +/// PeekThenConcreteChangeConverter (via ), not /// . Unknown $type values become /// . /// diff --git a/src/SIL.Harmony/Changes/ChangeContext.cs b/src/SIL.Harmony/Changes/ChangeContext.cs index b93de29..9ca0fd2 100644 --- a/src/SIL.Harmony/Changes/ChangeContext.cs +++ b/src/SIL.Harmony/Changes/ChangeContext.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using SIL.Harmony.Db; namespace SIL.Harmony.Changes; @@ -5,9 +6,9 @@ namespace SIL.Harmony.Changes; internal class ChangeContext : IChangeContext { private readonly SnapshotWorker _worker; - private readonly CrdtConfig _crdtConfig; + private readonly HarmonyConfig _crdtConfig; - internal ChangeContext(Commit commit, int commitIndex, IDictionary intermediateSnapshots, SnapshotWorker worker, CrdtConfig crdtConfig) + internal ChangeContext(Commit commit, int commitIndex, IDictionary intermediateSnapshots, SnapshotWorker worker, HarmonyConfig crdtConfig) { _worker = worker; _crdtConfig = crdtConfig; diff --git a/src/SIL.Harmony/Changes/PeekThenConcreteChangeConverter.cs b/src/SIL.Harmony/Changes/PeekThenConcreteChangeConverter.cs index 765df42..b129b07 100644 --- a/src/SIL.Harmony/Changes/PeekThenConcreteChangeConverter.cs +++ b/src/SIL.Harmony/Changes/PeekThenConcreteChangeConverter.cs @@ -7,7 +7,7 @@ namespace SIL.Harmony.Changes; /// /// Owns discrimination. Requires $type as the first JSON property -/// (matching synthetic write order from ). +/// (matching synthetic write order from ). /// Known discriminators deserialize via cached concrete ; /// unknown → preserving the raw payload. /// diff --git a/src/SIL.Harmony/Config/ChangeTypeListBuilder.cs b/src/SIL.Harmony/Config/ChangeTypeListBuilder.cs new file mode 100644 index 0000000..04f7216 --- /dev/null +++ b/src/SIL.Harmony/Config/ChangeTypeListBuilder.cs @@ -0,0 +1,35 @@ +using SIL.Harmony.Changes; +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Config; + +public readonly record struct RegisteredChangeType(Type Type, string Discriminator); + +public class ChangeTypeListBuilder +{ + private bool _frozen; + + /// + /// we call freeze when the builder is used to create a json serializer options, as it is not possible to add new types after that. + /// + public void Freeze() + { + _frozen = true; + } + + private void CheckFrozen() + { + if (_frozen) throw new InvalidOperationException($"{nameof(ChangeTypeListBuilder)} is frozen"); + } + + private readonly List _types = []; + public IReadOnlyList Types => _types.AsReadOnly(); + + public ChangeTypeListBuilder Add() where TDerived : IChange, IPolyType + { + CheckFrozen(); + if (_types.Any(t => t.Type == typeof(TDerived))) return this; + _types.Add(new RegisteredChangeType(typeof(TDerived), TDerived.TypeName)); + return this; + } +} diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/Config/HarmonyConfig.cs similarity index 57% rename from src/SIL.Harmony/CrdtConfig.cs rename to src/SIL.Harmony/Config/HarmonyConfig.cs index 4ccf11b..65556aa 100644 --- a/src/SIL.Harmony/CrdtConfig.cs +++ b/src/SIL.Harmony/Config/HarmonyConfig.cs @@ -1,19 +1,15 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.EntityFrameworkCore; -using SIL.Harmony.Adapters; using SIL.Harmony.Changes; using SIL.Harmony.Db; -using SIL.Harmony.Entities; using SIL.Harmony.Resource; -namespace SIL.Harmony; +namespace SIL.Harmony.Config; public delegate ValueTask BeforeSaveObjectDelegate(object obj, ObjectSnapshot snapshot); -public readonly record struct RegisteredChangeType(Type Type, string Discriminator); - -public class CrdtConfig +public class HarmonyConfig { /// /// recommended to increase query performance, as getting objects can just query the table for that object. @@ -34,7 +30,7 @@ public class CrdtConfig private readonly Lazy _lazyJsonSerializerOptions; private readonly Lazy _lazyChangeDiscriminatorMaps; - public CrdtConfig() + public HarmonyConfig() { _lazyChangeDiscriminatorMaps = new Lazy(BuildChangeDiscriminatorMaps); _lazyJsonSerializerOptions = new Lazy(CreateJsonSerializerOptions); @@ -166,144 +162,3 @@ public void AddRemoteResourceEntity(string? cachePath = null) }); } } - -internal class JsonOptionsBuilder -{ - private bool _frozen; - private readonly List> _configurations = []; - - public void Configure(Action configure) - { - CheckFrozen(); - _configurations.Add(configure); - } - - internal void ApplyTo(JsonSerializerOptions options) - { - foreach (var configure in _configurations) - configure(options); - Freeze(); - } - - private void Freeze() => _frozen = true; - - private void CheckFrozen() - { - if (_frozen) throw new InvalidOperationException($"{nameof(JsonOptionsBuilder)} is frozen"); - } -} - -public class ChangeTypeListBuilder -{ - private bool _frozen; - - /// - /// we call freeze when the builder is used to create a json serializer options, as it is not possible to add new types after that. - /// - public void Freeze() - { - _frozen = true; - } - - private void CheckFrozen() - { - if (_frozen) throw new InvalidOperationException($"{nameof(ChangeTypeListBuilder)} is frozen"); - } - private readonly List _types = []; - public IReadOnlyList Types => _types.AsReadOnly(); - - public ChangeTypeListBuilder Add() where TDerived : IChange, IPolyType - { - CheckFrozen(); - if (_types.Any(t => t.Type == typeof(TDerived))) return this; - _types.Add(new RegisteredChangeType(typeof(TDerived), TDerived.TypeName)); - return this; - } -} - -public class ObjectTypeListBuilder -{ - private bool _frozen; - - /// - /// we call freeze when the builder is used to create a json serializer options, as it is not possible to add new types after that. - /// - public void Freeze() - { - if (_frozen) return; - _frozen = true; - foreach (var registration in AdapterProviders.SelectMany(a => a.GetRegistrations())) - { - ModelConfigurations.Add((builder, config) => - { - if (!config.EnableProjectedTables) return; - var entity = registration.EntityBuilder(builder); - entity.HasOne(typeof(ObjectSnapshot)) - .WithOne() - .HasForeignKey(registration.ObjectDbType, ObjectSnapshot.ShadowRefName) - .OnDelete(DeleteBehavior.SetNull); - }); - } - } - - internal void CheckFrozen() - { - if (_frozen) throw new InvalidOperationException($"{nameof(ObjectTypeListBuilder)} is frozen"); - } - - internal Dictionary> JsonTypes { get; } = []; - internal List> ModelConfigurations { get; } = []; - internal List AdapterProviders { get; } = []; - - public DefaultAdapterProvider DefaultAdapter() - { - CheckFrozen(); - if (AdapterProviders.OfType().SingleOrDefault() is { } adapter) return adapter; - adapter = new DefaultAdapterProvider(this); - AdapterProviders.Add(adapter); - return adapter; - } - - /// - /// add a custom adapter for a common interface - /// this is required as CRDT objects must express their references and have an Id property - /// using a custom adapter allows your model to not take a dependency on Harmony - /// - /// - /// A common interface that all objects in your application implement - /// which System.Text.Json will deserialize your objects to, they must support polymorphic deserialization - /// - /// - /// This adapter will be serialized and stored in the database, - /// it should include the object it is adapting otherwise Harmony will not work - /// - /// - /// when another adapter has already been added or the config has been frozen - public CustomAdapterProvider CustomAdapter() - where TCommonInterface : class where TAdapter : class, ICustomAdapter, IPolyType - { - CheckFrozen(); - if (AdapterProviders.OfType>().SingleOrDefault() is { } adapter) return adapter; - adapter = new CustomAdapterProvider(this); - AdapterProviders.Add(adapter); - return adapter; - } - - internal IObjectBase Adapt(object obj) - { - if (AdapterProviders is [{ } defaultAdapter]) - { - return defaultAdapter.Adapt(obj); - } - - foreach (var objectAdapterProvider in AdapterProviders) - { - if (objectAdapterProvider.CanAdapt(obj)) - { - return objectAdapterProvider.Adapt(obj); - } - } - throw new ArgumentException($"Unable to adapt object of type {obj.GetType()}"); - } -} - diff --git a/src/SIL.Harmony/Config/JsonOptionsBuilder.cs b/src/SIL.Harmony/Config/JsonOptionsBuilder.cs new file mode 100644 index 0000000..9301c07 --- /dev/null +++ b/src/SIL.Harmony/Config/JsonOptionsBuilder.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace SIL.Harmony.Config; + +internal class JsonOptionsBuilder +{ + private bool _frozen; + private readonly List> _configurations = []; + + public void Configure(Action configure) + { + CheckFrozen(); + _configurations.Add(configure); + } + + internal void ApplyTo(JsonSerializerOptions options) + { + foreach (var configure in _configurations) + configure(options); + Freeze(); + } + + private void Freeze() => _frozen = true; + + private void CheckFrozen() + { + if (_frozen) throw new InvalidOperationException($"{nameof(JsonOptionsBuilder)} is frozen"); + } +} diff --git a/src/SIL.Harmony/Config/ObjectTypeListBuilder.cs b/src/SIL.Harmony/Config/ObjectTypeListBuilder.cs new file mode 100644 index 0000000..a12750e --- /dev/null +++ b/src/SIL.Harmony/Config/ObjectTypeListBuilder.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Serialization.Metadata; +using Microsoft.EntityFrameworkCore; +using SIL.Harmony.Adapters; +using SIL.Harmony.Db; +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Config; + +public class ObjectTypeListBuilder +{ + private bool _frozen; + + /// + /// we call freeze when the builder is used to create a json serializer options, as it is not possible to add new types after that. + /// + public void Freeze() + { + if (_frozen) return; + _frozen = true; + foreach (var registration in AdapterProviders.SelectMany(a => a.GetRegistrations())) + { + ModelConfigurations.Add((builder, config) => + { + if (!config.EnableProjectedTables) return; + var entity = registration.EntityBuilder(builder); + entity.HasOne(typeof(ObjectSnapshot)) + .WithOne() + .HasForeignKey(registration.ObjectDbType, ObjectSnapshot.ShadowRefName) + .OnDelete(DeleteBehavior.SetNull); + }); + } + } + + internal void CheckFrozen() + { + if (_frozen) throw new InvalidOperationException($"{nameof(ObjectTypeListBuilder)} is frozen"); + } + + internal Dictionary> JsonTypes { get; } = []; + internal List> ModelConfigurations { get; } = []; + internal List AdapterProviders { get; } = []; + + public DefaultAdapterProvider DefaultAdapter() + { + CheckFrozen(); + if (AdapterProviders.OfType().SingleOrDefault() is { } adapter) return adapter; + adapter = new DefaultAdapterProvider(this); + AdapterProviders.Add(adapter); + return adapter; + } + + /// + /// add a custom adapter for a common interface + /// this is required as CRDT objects must express their references and have an Id property + /// using a custom adapter allows your model to not take a dependency on Harmony + /// + /// + /// A common interface that all objects in your application implement + /// which System.Text.Json will deserialize your objects to, they must support polymorphic deserialization + /// + /// + /// This adapter will be serialized and stored in the database, + /// it should include the object it is adapting otherwise Harmony will not work + /// + /// + /// when another adapter has already been added or the config has been frozen + public CustomAdapterProvider CustomAdapter() + where TCommonInterface : class where TAdapter : class, ICustomAdapter, IPolyType + { + CheckFrozen(); + if (AdapterProviders.OfType>().SingleOrDefault() is { } adapter) return adapter; + adapter = new CustomAdapterProvider(this); + AdapterProviders.Add(adapter); + return adapter; + } + + internal IObjectBase Adapt(object obj) + { + if (AdapterProviders is [{ } defaultAdapter]) + { + return defaultAdapter.Adapt(obj); + } + + foreach (var objectAdapterProvider in AdapterProviders) + { + if (objectAdapterProvider.CanAdapt(obj)) + { + return objectAdapterProvider.Adapt(obj); + } + } + throw new ArgumentException($"Unable to adapt object of type {obj.GetType()}"); + } +} diff --git a/src/SIL.Harmony/CrdtKernel.cs b/src/SIL.Harmony/CrdtKernel.cs index 7e4d888..e1e6302 100644 --- a/src/SIL.Harmony/CrdtKernel.cs +++ b/src/SIL.Harmony/CrdtKernel.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using SIL.Harmony.Config; using SIL.Harmony.Db; namespace SIL.Harmony; @@ -10,7 +11,7 @@ namespace SIL.Harmony; public static class CrdtKernel { public static IServiceCollection AddCrdtDataDbFactory(this IServiceCollection services, - Action configureCrdt) where TContext : DbContext, ICrdtDbContext + Action configureCrdt) where TContext : DbContext, ICrdtDbContext { services.AddCrdtDataCore(configureCrdt); services.AddScoped>(); @@ -18,7 +19,7 @@ public static IServiceCollection AddCrdtDataDbFactory(this IServiceCol } public static IServiceCollection AddCrdtData(this IServiceCollection services, - Action configureCrdt) where TContext : DbContext, ICrdtDbContext + Action configureCrdt) where TContext : DbContext, ICrdtDbContext { services.AddCrdtDataCore(configureCrdt); services.AddScoped>(); @@ -26,29 +27,29 @@ public static IServiceCollection AddCrdtData(this IServiceCollection s } public static IServiceCollection AddCrdtRemoteResources(this IServiceCollection services, - Action? configureCrdt = null, string? cachePath = null) + Action? configureCrdt = null, string? cachePath = null) where TMetadata : class { - services.Configure(config => + services.Configure(config => { config.AddRemoteResourceEntity(cachePath); configureCrdt?.Invoke(config); }); services.AddScoped>(provider => new ResourceService( provider.GetRequiredService(), - provider.GetRequiredService>(), + provider.GetRequiredService>(), provider.GetRequiredService(), provider.GetRequiredService>>() )); return services; } - public static IServiceCollection AddCrdtDataCore(this IServiceCollection services, Action configureCrdt) + public static IServiceCollection AddCrdtDataCore(this IServiceCollection services, Action configureCrdt) { services.AddLogging(); - services.AddOptions().Configure(configureCrdt) + services.AddOptions().Configure(configureCrdt) .PostConfigure(crdtConfig => crdtConfig.ObjectTypeListBuilder.Freeze()); - services.AddSingleton(sp => sp.GetRequiredService>().Value.JsonSerializerOptions); + services.AddSingleton(sp => sp.GetRequiredService>().Value.JsonSerializerOptions); services.AddSingleton(TimeProvider.System); services.AddScoped(NewTimeProvider); services.AddScoped(); @@ -57,7 +58,7 @@ public static IServiceCollection AddCrdtDataCore(this IServiceCollection service provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService>(), + provider.GetRequiredService>(), provider.GetRequiredService>() )); return services; diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 02acd36..01e5219 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Nito.AsyncEx; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Db; namespace SIL.Harmony; @@ -21,14 +22,14 @@ public class DataModel : ISyncable, IAsyncDisposable private readonly CrdtRepositoryFactory _crdtRepositoryFactory; private readonly JsonSerializerOptions _serializerOptions; private readonly IHybridDateTimeProvider _timeProvider; - private readonly IOptions _crdtConfig; + private readonly IOptions _crdtConfig; private readonly ILogger _logger; //constructor must be internal because CrdtRepository is internal internal DataModel(CrdtRepositoryFactory crdtRepositoryFactory, JsonSerializerOptions serializerOptions, IHybridDateTimeProvider timeProvider, - IOptions crdtConfig, + IOptions crdtConfig, ILogger logger) { _crdtRepositoryFactory = crdtRepositoryFactory; diff --git a/src/SIL.Harmony/Db/CrdtDbContextOptionsExtensions.cs b/src/SIL.Harmony/Db/CrdtDbContextOptionsExtensions.cs index dc402e8..48cb1e2 100644 --- a/src/SIL.Harmony/Db/CrdtDbContextOptionsExtensions.cs +++ b/src/SIL.Harmony/Db/CrdtDbContextOptionsExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using SIL.Harmony.Config; using SIL.Harmony.Db.EntityConfig; namespace SIL.Harmony.Db; @@ -6,7 +7,7 @@ namespace SIL.Harmony.Db; public static class CrdtDbContextModelExtensions { public static ModelBuilder UseCrdt(this ModelBuilder modelBuilder, - CrdtConfig crdtConfig) + HarmonyConfig crdtConfig) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(CommitEntityConfig).Assembly) .ApplyConfiguration(new SnapshotEntityConfig(crdtConfig.JsonSerializerOptions)) diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs index fb23e53..d7a8a10 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Nito.AsyncEx; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Resource; namespace SIL.Harmony.Db; @@ -49,10 +50,10 @@ internal class CrdtRepository : IDisposable, IAsyncDisposable private readonly AsyncLock _lock; private readonly ICrdtDbContext _dbContext; - private readonly IOptions _crdtConfig; + private readonly IOptions _crdtConfig; private readonly ILogger _logger; - public CrdtRepository(ICrdtDbContext dbContext, IOptions crdtConfig, + public CrdtRepository(ICrdtDbContext dbContext, IOptions crdtConfig, ILogger logger, Commit? ignoreChangesAfter = null) { diff --git a/src/SIL.Harmony/ResourceService.cs b/src/SIL.Harmony/ResourceService.cs index a225788..41bcfe1 100644 --- a/src/SIL.Harmony/ResourceService.cs +++ b/src/SIL.Harmony/ResourceService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Db; using SIL.Harmony.Helpers; using SIL.Harmony.Resource; @@ -11,11 +12,11 @@ namespace SIL.Harmony; public class ResourceService where TMetadata : class { private readonly CrdtRepositoryFactory _crdtRepositoryFactory; - private readonly IOptions _crdtConfig; + private readonly IOptions _crdtConfig; private readonly DataModel _dataModel; private readonly ILogger> _logger; - internal ResourceService(CrdtRepositoryFactory crdtRepositoryFactory, IOptions crdtConfig, + internal ResourceService(CrdtRepositoryFactory crdtRepositoryFactory, IOptions crdtConfig, DataModel dataModel, ILogger> logger) { _crdtRepositoryFactory = crdtRepositoryFactory; diff --git a/src/SIL.Harmony/SnapshotWorker.cs b/src/SIL.Harmony/SnapshotWorker.cs index aa78f80..37a8c1e 100644 --- a/src/SIL.Harmony/SnapshotWorker.cs +++ b/src/SIL.Harmony/SnapshotWorker.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; using SIL.Harmony.Changes; +using SIL.Harmony.Config; using SIL.Harmony.Db; namespace SIL.Harmony; @@ -12,7 +13,7 @@ internal class SnapshotWorker { private readonly Dictionary _snapshotLookup; private readonly CrdtRepository _crdtRepository; - private readonly CrdtConfig _crdtConfig; + private readonly HarmonyConfig _crdtConfig; private readonly Dictionary _pendingSnapshots = []; private readonly Dictionary _rootSnapshots = []; private readonly List _newIntermediateSnapshots = []; @@ -20,7 +21,7 @@ internal class SnapshotWorker private SnapshotWorker(Dictionary snapshots, Dictionary snapshotLookup, CrdtRepository crdtRepository, - CrdtConfig crdtConfig) + HarmonyConfig crdtConfig) { _pendingSnapshots = snapshots; _crdtRepository = crdtRepository; @@ -32,7 +33,7 @@ internal static async Task> ApplyCommitsToSnaps Dictionary snapshots, CrdtRepository crdtRepository, SortedSet commits, - CrdtConfig crdtConfig) + HarmonyConfig crdtConfig) { //we need to pass in the snapshots because we expect it to be modified, this is intended. //if the constructor makes a copy in the future this will need to be updated @@ -45,7 +46,7 @@ internal static async Task> ApplyCommitsToSnaps /// internal SnapshotWorker(Dictionary snapshotLookup, CrdtRepository crdtRepository, - CrdtConfig crdtConfig) : this([], snapshotLookup, crdtRepository, crdtConfig) + HarmonyConfig crdtConfig) : this([], snapshotLookup, crdtRepository, crdtConfig) { } From b5d96be9f3ed4b10d9728243242782df70cbdb09 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 23 Jul 2026 09:07:10 +0700 Subject: [PATCH 4/4] Make freeze internal Co-authored-by: Tim Haasdyk --- src/SIL.Harmony/Config/ChangeTypeListBuilder.cs | 2 +- src/SIL.Harmony/Config/ObjectTypeListBuilder.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SIL.Harmony/Config/ChangeTypeListBuilder.cs b/src/SIL.Harmony/Config/ChangeTypeListBuilder.cs index 04f7216..1fd4b1c 100644 --- a/src/SIL.Harmony/Config/ChangeTypeListBuilder.cs +++ b/src/SIL.Harmony/Config/ChangeTypeListBuilder.cs @@ -12,7 +12,7 @@ public class ChangeTypeListBuilder /// /// we call freeze when the builder is used to create a json serializer options, as it is not possible to add new types after that. /// - public void Freeze() + internal void Freeze() { _frozen = true; } diff --git a/src/SIL.Harmony/Config/ObjectTypeListBuilder.cs b/src/SIL.Harmony/Config/ObjectTypeListBuilder.cs index a12750e..10c7f29 100644 --- a/src/SIL.Harmony/Config/ObjectTypeListBuilder.cs +++ b/src/SIL.Harmony/Config/ObjectTypeListBuilder.cs @@ -13,7 +13,7 @@ public class ObjectTypeListBuilder /// /// we call freeze when the builder is used to create a json serializer options, as it is not possible to add new types after that. /// - public void Freeze() + internal void Freeze() { if (_frozen) return; _frozen = true;