diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 10bf027b84..ecc9adbecb 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -109,9 +109,9 @@ - - - + + + diff --git a/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs b/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs index 4ebbf51797..45a3ec6cdb 100644 --- a/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs +++ b/backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using FwLiteMaui.Services; using FwLiteShared; using FwLiteShared.Auth; @@ -116,7 +117,7 @@ public static void AddFwLiteMauiServices(this IServiceCollection services, config.CacheFileName = fwLiteMauiConfig.AuthCacheFilePath; config.SystemWebViewLogin = true; }); - services.Configure(config => + services.Configure(config => { config.FailedSyncOutputPath = Path.Combine(baseDataPath, "failedSyncs"); config.LocalResourceCachePath = Path.Combine(baseDataPath, "localResourcesCache"); diff --git a/backend/FwLite/FwLiteProjectSync/ProjectSnapshotService.cs b/backend/FwLite/FwLiteProjectSync/ProjectSnapshotService.cs index e25e7a3cdd..15be672884 100644 --- a/backend/FwLite/FwLiteProjectSync/ProjectSnapshotService.cs +++ b/backend/FwLite/FwLiteProjectSync/ProjectSnapshotService.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Text.Json; using FwDataMiniLcmBridge; using LcmCrdt; @@ -7,17 +8,17 @@ namespace FwLiteProjectSync; -public class ProjectSnapshotService(IOptions crdtConfig) +public class ProjectSnapshotService(IOptions harmonyConfig) { public virtual async Task GetProjectSnapshot(FwDataProject project) { var snapshotPath = SnapshotPath(project); if (!File.Exists(snapshotPath)) return null; await using var file = File.OpenRead(snapshotPath); - // crdtConfig's options are fine for reading even though they "exclude" [MiniLcmInternal] members: + // harmonyConfig's options are fine for reading even though they "exclude" [MiniLcmInternal] members: // the modifier only nulls the getter (the write side), so deserialization still populates those // members (Order, entity Ids) via their setters. See SaveProjectSnapshot for why they're written. - return await JsonSerializer.DeserializeAsync(file, crdtConfig.Value.JsonSerializerOptions); + return await JsonSerializer.DeserializeAsync(file, harmonyConfig.Value.JsonSerializerOptions); } public virtual async Task RegenerateProjectSnapshot(IMiniLcmReadApi crdtApi, FwDataProject project, bool keepBackup) @@ -52,7 +53,7 @@ internal static async Task SaveProjectSnapshot(FwDataProject project, ProjectSna } await using var file = File.Create(snapshotPath); - // Serialize with default options, not crdtConfig's: the CRDT options hide [MiniLcmInternal] members + // Serialize with default options, not harmonyConfig's: the CRDT options hide [MiniLcmInternal] members // (the internal Order values and entity Ids) — that's the API's presentation view, which omits // bookkeeping callers don't need. The snapshot is a stored record, so we keep the full object graph. // The sync diff itself keys off business fields and list order, not these, so this is about a diff --git a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs index 4c37077ffd..cdbbc225d1 100644 --- a/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs +++ b/backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Net; using FwLiteShared.AppUpdate; using FwLiteShared.Auth; @@ -56,8 +57,8 @@ public static IServiceCollection AddFwLiteShared(this IServiceCollection service services.AddOptions().BindConfiguration("FwLite"); services.DecorateConstructor((provider, runtime) => { - var crdtConfig = provider.GetRequiredService>().Value; - runtime.ConfigureJsonSerializerOptions(crdtConfig); + var harmonyConfig = provider.GetRequiredService>().Value; + runtime.ConfigureJsonSerializerOptions(harmonyConfig); }); return services; } diff --git a/backend/FwLite/FwLiteShared/Services/FwLiteJson.cs b/backend/FwLite/FwLiteShared/Services/FwLiteJson.cs index e84a843c72..be01d63974 100644 --- a/backend/FwLite/FwLiteShared/Services/FwLiteJson.cs +++ b/backend/FwLite/FwLiteShared/Services/FwLiteJson.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -12,7 +13,7 @@ public static class FwLiteJson private static readonly PropertyInfo? _jsRuntimeJsonOptionsProperty = typeof(JSRuntime).GetProperty("JsonSerializerOptions", BindingFlags.NonPublic | BindingFlags.Instance); - public static void ConfigureJsonSerializerOptions(this IJSRuntime jsRuntime, CrdtConfig crdtConfig) + public static void ConfigureJsonSerializerOptions(this IJSRuntime jsRuntime, HarmonyConfig harmonyConfig) { //this is not supported, see: https://github.com/dotnet/aspnetcore/issues/12685 //doing it anyway, otherwise our serialization will be broken @@ -21,7 +22,7 @@ public static void ConfigureJsonSerializerOptions(this IJSRuntime jsRuntime, Crd var options = (JsonSerializerOptions?)_jsRuntimeJsonOptionsProperty.GetValue(jsRuntime, null); if (options is null) throw new InvalidOperationException("JSRuntime.JsonSerializerOptions returned null"); options.TypeInfoResolver = (options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver()) - .WithAddedModifier(crdtConfig.MakeJsonTypeModifier()) + .WithAddedModifier(harmonyConfig.MakeJsonTypeModifier()) .AddExternalMiniLcmModifiers(); } } diff --git a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs index 9b3dc10380..69964afb50 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ChangeTypesCodeGenerator.cs @@ -7,16 +7,16 @@ namespace FwLiteShared.TypeGen; /// /// Emits a `ChangeType` string-literal union and a `knownChangeTypes` array built from the registered CRDT -/// change types () and each type's serialized $type -/// discriminator (). Attached to the ChangeType -/// marker; suppresses the marker's own output. +/// change types and each type's serialized $type discriminator, taken straight from Harmony's +/// registration () so the generated list can't drift from +/// what the serializer writes. Attached to the ChangeType marker; suppresses the marker's own output. /// public class ChangeTypesCodeGenerator : ClassCodeGenerator { public override RtClass GenerateNode(Type element, RtClass result, TypeResolver resolver) { - var typeNames = LcmCrdtKernel.AllChangeTypes() - .Select(HistoryService.GetChangeTypeKeyFromType) + var typeNames = LcmCrdtKernel.AllRegisteredChanges() + .Select(c => c.Discriminator) .Distinct() .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index d4d4427eba..ac12543e8d 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Drawing; using System.Reflection; using System.Text.Json.Serialization; @@ -80,7 +81,7 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) builder.ExportAsThirdParty().WithName("IRichMultiString").Imports([ new() { From = "$lib/dotnet-types/i-multi-string", Target = "type {IRichMultiString}" } ]); - var config = new CrdtConfig(); + var config = new HarmonyConfig(); LcmCrdtKernel.ConfigureCrdt(config); builder.ExportAsInterfaces([ ..config.ObjectTypes, diff --git a/backend/FwLite/FwLiteWeb/FwLiteWebKernel.cs b/backend/FwLite/FwLiteWeb/FwLiteWebKernel.cs index d02dc4bce9..ce39fbdcfd 100644 --- a/backend/FwLite/FwLiteWeb/FwLiteWebKernel.cs +++ b/backend/FwLite/FwLiteWeb/FwLiteWebKernel.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using FwDataMiniLcmBridge; using FwLiteProjectSync; using SIL.Harmony; @@ -29,9 +30,15 @@ public static IServiceCollection AddFwLiteWebServices(this IServiceCollection se services.AddSingleton(); services.AddOptions().BindConfiguration("FwLiteWeb"); - services.AddOptions().PostConfigure>((jsonOptions, crdtConfig) => + services.AddOptions().PostConfigure>((jsonOptions, harmonyConfig) => { - jsonOptions.SerializerOptions.TypeInfoResolver = crdtConfig.Value.MakeLcmCrdtExternalJsonTypeResolver(); + // Copy both the type resolver and Harmony's converters: IChange polymorphism now lives in an + // internal converter (see MakeLcmCrdtExternalJsonOptions), so the resolver alone can't decode + // ChangeEntity in sync payloads. + var externalOptions = harmonyConfig.Value.MakeLcmCrdtExternalJsonOptions(); + jsonOptions.SerializerOptions.TypeInfoResolver = externalOptions.TypeInfoResolver; + foreach (var converter in externalOptions.Converters) + jsonOptions.SerializerOptions.Converters.Add(converter); }); return services; } diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/ChangeSerializationTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/ChangeSerializationTests.cs index 524c9a8bf7..b309b3cce5 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/ChangeSerializationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Changes/ChangeSerializationTests.cs @@ -54,6 +54,20 @@ public void CanRoundTripChanges(IChange change) newChange.Should().BeEquivalentTo(change); } + [Theory] + [MemberData(nameof(Changes))] + public void CanRoundTripChangesAsIChangeWithExternalOptions(IChange change) + { + // The sync client, web host, and debugger all (de)serialize change fields as the abstract IChange + // using the external (Web) options. That relies on Harmony's polymorphic converter, not just the + // type resolver — regression guard against the "Deserialization of interface or abstract types is + // not supported. Type 'IChange'" break when only the resolver was wired onto the external options. + var options = TestJsonOptions.External(); + var json = JsonSerializer.Serialize(change, options); + var newChange = JsonSerializer.Deserialize(json, options); + newChange.Should().BeEquivalentTo(change); + } + [Fact] public void ChangesIncludesAllValidChangeTypes() { diff --git a/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs b/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs index 1529c7ed97..5938943f3f 100644 --- a/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using FluentAssertions.Execution; using LcmCrdt.Changes; using LcmCrdt.Changes.Entries; @@ -23,11 +24,11 @@ public class ConfigRegistrationTests typeof(DeleteChange>)//Not used, instead DeleteRemoteResourceChange is used ]; - private readonly CrdtConfig _config; + private readonly HarmonyConfig _config; public ConfigRegistrationTests() { - _config = new CrdtConfig(); + _config = new HarmonyConfig(); LcmCrdtKernel.ConfigureCrdt(_config); } @@ -57,7 +58,7 @@ public void AllChangesAreRegistered() .Append(typeof(DeleteChange<>)) .ToLookup(t => t.IsGenericType); allChangeTypes.Should().NotBeEmpty(); - var registeredChangeTypes = _config.ChangeTypes.ToHashSet(); + var registeredChangeTypes = _config.ChangeTypes.Select(t => t.Type).ToHashSet(); using var _ = new AssertionScope(); //non generics foreach (var changeType in allChangeTypes[false]) diff --git a/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs index b8c985e6c4..6ea30015d6 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/MigrationTests.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json; @@ -60,7 +61,7 @@ public async Task VerifyAfterMigrationFromScriptedDb(RegressionTestHelper.Regres { await _helper.InitializeAsync(regressionVersion); var api = _helper.Services.GetRequiredService(); - var crdtConfig = _helper.Services.GetRequiredService>().Value; + var harmonyConfig = _helper.Services.GetRequiredService>().Value; await using var dbContext = await _helper.Services.GetRequiredService().CreateDbContextAsync(); var snapshots = await dbContext.Snapshots.AsNoTracking() @@ -112,7 +113,7 @@ public async Task VerifyRegeneratedSnapshotsAfterMigrationFromScriptedDb(Regress { await _helper.InitializeAsync(regressionVersion); var api = _helper.Services.GetRequiredService(); - var crdtConfig = _helper.Services.GetRequiredService>().Value; + var harmonyConfig = _helper.Services.GetRequiredService>().Value; await using var dbContext = await _helper.Services.GetRequiredService().CreateDbContextAsync(); await using var dataModel = _helper.Services.GetRequiredService(); diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs index 57b4f9f19a..a63e639545 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using FluentAssertions.Execution; @@ -20,7 +21,7 @@ public class DataModelSnapshotTests : IAsyncLifetime protected readonly AsyncServiceScope _services; private readonly LcmCrdtDbContext _crdtDbContext; - private CrdtConfig _crdtConfig; + private HarmonyConfig _crdtConfig; private CrdtProject _crdtProject; private readonly JsonSerializerOptions _jsonSerializerOptions = TestJsonOptions.Harmony(); @@ -33,7 +34,7 @@ public DataModelSnapshotTests() .BuildServiceProvider(); _services = services.CreateAsyncScope(); _crdtDbContext = _services.ServiceProvider.GetRequiredService>().CreateDbContext(); - _crdtConfig = _services.ServiceProvider.GetRequiredService>().Value; + _crdtConfig = _services.ServiceProvider.GetRequiredService>().Value; } public async Task InitializeAsync() @@ -65,7 +66,7 @@ private IEnumerable GetPolymorphicTypesFor(Type type) { var polymorphismOptions = _jsonSerializerOptions.GetTypeInfo(type) .PolymorphismOptions; - polymorphismOptions.Should().NotBeNull("type {type} should only be called if it's configured properly", type); + polymorphismOptions.Should().NotBeNull("type {0} should only be called if it's configured properly", type); polymorphismOptions.TypeDiscriminatorPropertyName.Should().Be("$type"); return polymorphismOptions.DerivedTypes.OrderBy(t => t.DerivedType.FullName); } @@ -74,7 +75,10 @@ private IEnumerable GetPolymorphicTypesFor(Type type) [Trait("Category", "Verified")] public async Task VerifyChangeModels() { - await Verify(GetPolymorphicTypesFor(typeof(IChange))); + await Verify(LcmCrdtKernel.AllRegisteredChanges() + //map to JsonDerivedType to match the verification format previously used + .Select(c => new JsonDerivedType(c.Type, c.Discriminator)) + .OrderBy(j => j.DerivedType.FullName)); } [Fact] diff --git a/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs b/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs index ec34d2245c..ce1145d018 100644 --- a/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/EntityCopyMethodTests.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using FluentAssertions.Equivalency; using FluentAssertions.Execution; using MiniLcm.Media; @@ -14,9 +15,9 @@ public class EntityCopyMethodTests public static IEnumerable GetEntityTypes() { - var crdtConfig = new CrdtConfig(); - LcmCrdtKernel.ConfigureCrdt(crdtConfig); - return crdtConfig.ObjectTypes + var harmonyConfig = new HarmonyConfig(); + LcmCrdtKernel.ConfigureCrdt(harmonyConfig); + return harmonyConfig.ObjectTypes .Except([typeof(RemoteResource)])//exclude remote resource as it's a harmony defined type, not miniLcm .Select(t => new object[] { t }); } diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs index 0926263cd0..e30f61cb67 100644 --- a/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmApiFixture.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Diagnostics; using LcmCrdt.MediaServer; using LcmCrdt.Objects; @@ -20,7 +21,7 @@ public class MiniLcmApiFixture : IAsyncLifetime, IAsyncDisposable public CrdtMiniLcmApi Api => (CrdtMiniLcmApi)_services.ServiceProvider.GetRequiredService(); public DataModel DataModel => _services.ServiceProvider.GetRequiredService(); public LcmCrdtDbContext DbContext => _crdtDbContext ?? throw new InvalidOperationException("MiniLcmApiFixture not initialized"); - public CrdtConfig CrdtConfig => _services.ServiceProvider.GetRequiredService>().Value; + public HarmonyConfig HarmonyConfig => _services.ServiceProvider.GetRequiredService>().Value; public T GetService() where T : notnull { diff --git a/backend/FwLite/LcmCrdt.Tests/TestJsonOptions.cs b/backend/FwLite/LcmCrdt.Tests/TestJsonOptions.cs index 76bc8ad5a3..d453659b59 100644 --- a/backend/FwLite/LcmCrdt.Tests/TestJsonOptions.cs +++ b/backend/FwLite/LcmCrdt.Tests/TestJsonOptions.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Text.Json; namespace LcmCrdt.Tests; @@ -6,17 +7,16 @@ public static class TestJsonOptions { public static JsonSerializerOptions External() { - var config = new CrdtConfig(); + var config = new HarmonyConfig(); LcmCrdtKernel.ConfigureCrdt(config); - return new JsonSerializerOptions(JsonSerializerDefaults.General) - { - TypeInfoResolver = config.MakeLcmCrdtExternalJsonTypeResolver(), - }; + // Full external options (resolver + Harmony's IChange converter), matching what the sync client, + // web host, and debugger use — a resolver alone can't deserialize IChange. + return config.MakeLcmCrdtExternalJsonOptions(); } public static JsonSerializerOptions Harmony() { - var config = new CrdtConfig(); + var config = new HarmonyConfig(); LcmCrdtKernel.ConfigureCrdt(config); return config.JsonSerializerOptions; } diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index a7aa058dc7..42506f26da 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; @@ -209,7 +210,7 @@ public virtual async Task CreateProjectFromTemplate( AfterCreate = async (provider, project) => { var api = provider.GetRequiredService(); - var jsonOptions = provider.GetRequiredService>().Value.JsonSerializerOptions; + var jsonOptions = provider.GetRequiredService>().Value.JsonSerializerOptions; var snapshot = ProjectTemplate.CreateNewSnapshot(jsonOptions, vernacularWs, analysisWs); var commitMetadataInterceptor = provider.GetRequiredService(); using (commitMetadataInterceptor.Intercept(CommitHelpers.StampAsTemplate)) @@ -344,7 +345,7 @@ private Task EnsureDeleteProject(string sqliteFile, bool suppressException = fal public async Task DeleteProject(string code) { var project = GetProject(code) ?? throw new InvalidOperationException($"Project {code} not found"); - var projectResourceCachePath = LcmMediaService.ProjectCachePath(project, provider.GetRequiredService>().Value); + var projectResourceCachePath = LcmMediaService.ProjectCachePath(project, provider.GetRequiredService>().Value); if (Directory.Exists(projectResourceCachePath)) Directory.Delete(projectResourceCachePath, true); await EnsureDeleteProject(project.DbPath); } diff --git a/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs b/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs index de1a1c856e..1f9fbd6042 100644 --- a/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs +++ b/backend/FwLite/LcmCrdt/Data/SetupCollationInterceptor.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Data; using System.Data.Common; using System.Globalization; @@ -13,7 +14,7 @@ namespace LcmCrdt.Data; -public class SetupCollationInterceptor(IMemoryCache cache, IMiniLcmCultureProvider cultureProvider, IOptions crdtConfig) : IDbConnectionInterceptor, ISaveChangesInterceptor, IConnectionInterceptor +public class SetupCollationInterceptor(IMemoryCache cache, IMiniLcmCultureProvider cultureProvider, IOptions harmonyConfig) : IDbConnectionInterceptor, ISaveChangesInterceptor, IConnectionInterceptor { private static string? WsTableName = null; private WritingSystem[] GetWritingSystems(DbConnection connection, LcmCrdtDbContext? dbContext = null) @@ -29,7 +30,7 @@ private WritingSystem[] GetWritingSystems(DbConnection connection, LcmCrdtDbCont { var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseSqlite(connection); - localContext = new LcmCrdtDbContext(optionsBuilder.Options, crdtConfig); + localContext = new LcmCrdtDbContext(optionsBuilder.Options, harmonyConfig); } try diff --git a/backend/FwLite/LcmCrdt/HistoryService.cs b/backend/FwLite/LcmCrdt/HistoryService.cs index a553f40034..89914fe874 100644 --- a/backend/FwLite/LcmCrdt/HistoryService.cs +++ b/backend/FwLite/LcmCrdt/HistoryService.cs @@ -138,11 +138,11 @@ public async Task ListActivityChangeTypes() .Select(g => new KeyValuePair(g.Key.ChangeTypeKey, g.Count())) .ToDictionaryAsyncLinqToDB(p => p.Key, p => p.Value); - var registeredTypes = LcmCrdtKernel.AllChangeTypes() - .Select(t => new ActivityChangeType( - GetChangeTypeKeyFromType(t), - ChangeTypeLabel(t), - changeCounts.GetValueOrDefault(GetChangeTypeKeyFromType(t)))) + var registeredTypes = LcmCrdtKernel.AllRegisteredChanges() + .Select(c => new ActivityChangeType( + c.Discriminator, + ChangeTypeLabel(c.Type), + changeCounts.GetValueOrDefault(c.Discriminator))) .Where(t => t.CommitCount > 0) .OrderBy(t => t.Label) .ToArray(); @@ -241,21 +241,6 @@ private static IQueryable ApplyActivitySort(IQueryable commits, }; } - /// - /// The serialized $type discriminator of a change type. Mirrors Harmony's own logic: generic/shared - /// changes (jsonPatch:, delete:, SetOrderChange:) define a custom static TypeName, while a dedicated - /// change class serializes under its CLR type name. Single source of truth — the ChangeTypes TS codegen - /// uses this too, so the generated list can't drift from the runtime discriminators. - /// - public static string GetChangeTypeKeyFromType(Type changeType) - { - var typeNameProp = changeType.GetProperty(nameof(IPolyType.TypeName), - System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.FlattenHierarchy); - if (typeNameProp?.GetValue(null) is string name) - return name; - return changeType.Name; - } - public static string ChangeTypeLabel(Type changeType) { if (changeType.IsGenericType && changeType.Name.Contains("JsonPatch", StringComparison.Ordinal)) diff --git a/backend/FwLite/LcmCrdt/Json.cs b/backend/FwLite/LcmCrdt/Json.cs index 0ba71452ee..763b413958 100644 --- a/backend/FwLite/LcmCrdt/Json.cs +++ b/backend/FwLite/LcmCrdt/Json.cs @@ -1,5 +1,7 @@ +using SIL.Harmony.Config; using System.Linq.Expressions; using System.Reflection; +using System.Text.Json; using System.Text.Json.Serialization.Metadata; using LinqToDB; using LinqToDB.Internal.SqlQuery; @@ -238,13 +240,36 @@ internal record JsonEach( [property: Column("fullkey")] string FullKey, [property: Column("path")] string Path); - public static IJsonTypeInfoResolver MakeLcmCrdtExternalJsonTypeResolver(this CrdtConfig config) + public static IJsonTypeInfoResolver MakeLcmCrdtExternalJsonTypeResolver(this HarmonyConfig config) { var resolver = config.MakeJsonTypeResolver(); resolver = resolver.AddExternalMiniLcmModifiers(); return resolver; } + /// + /// Web-cased for talking to the sync server (camelCase wire format), + /// with the external MiniLcm modifiers layered on. + /// + /// + /// SIL.Harmony moved polymorphism from the type-info resolver + /// into an internal JsonConverter<IChange> that it only adds to its own + /// . A resolver alone can therefore no longer read/write + /// changes, so we copy Harmony's converters onto our Web-cased options. Without this, deserializing a + /// server response throws "Deserialization of interface or abstract types is not supported. Type + /// 'SIL.Harmony.Changes.IChange'". + /// + public static JsonSerializerOptions MakeLcmCrdtExternalJsonOptions(this HarmonyConfig config) + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = config.MakeLcmCrdtExternalJsonTypeResolver() + }; + foreach (var converter in config.JsonSerializerOptions.Converters) + options.Converters.Add(converter); + return options; + } + internal static void ExampleSentenceTranslationModifier(JsonTypeInfo typeInfo) { if (typeInfo.Type == typeof(ExampleSentence)) diff --git a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs index e1fcf6eac3..350edc3cf7 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.ComponentModel; using System.Text.Json; using LcmCrdt.Data; @@ -12,7 +13,7 @@ namespace LcmCrdt; public class LcmCrdtDbContext( DbContextOptions dbContextOptions, - IOptions options + IOptions options ) : DbContext(dbContextOptions), ICrdtDbContext { diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index 4e5f4be1d3..5922d26bb3 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Text.Json; using SIL.Harmony; +using SIL.Harmony.Config; using SIL.Harmony.Linq2db; using SIL.Harmony.Core; using SIL.Harmony.Changes; @@ -66,9 +67,9 @@ public static IServiceCollection AddLcmCrdtClientCore(this IServiceCollection se config => ConfigureCrdt(config, false)//don't add remote resources because they are added in AddCrdtRemoteResources ); services.AddCrdtRemoteResources(); - services.AddOptions().PostConfigure((CrdtConfig crdtConfig, IOptions lcmConfig) => + services.AddOptions().PostConfigure((HarmonyConfig harmonyConfig, IOptions lcmConfig) => { - crdtConfig.LocalResourceCachePath = Path.Combine(lcmConfig.Value.ProjectPath, "localResourcesCache"); + harmonyConfig.LocalResourceCachePath = Path.Combine(lcmConfig.Value.ProjectPath, "localResourcesCache"); }); services.AddScoped(); services.AddScoped(); @@ -86,11 +87,8 @@ public static IServiceCollection AddLcmCrdtClientCore(this IServiceCollection se services.AddHttpClient(); services.AddSingleton(provider => new RefitSettings { - ContentSerializer = new SystemTextJsonContentSerializer(new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = provider.GetRequiredService>().Value - .MakeLcmCrdtExternalJsonTypeResolver() - }) + ContentSerializer = new SystemTextJsonContentSerializer( + provider.GetRequiredService>().Value.MakeLcmCrdtExternalJsonOptions()) }); services.AddSingleton(); services.AddSingleton(); @@ -187,7 +185,7 @@ private static Expression>> EntryPublishInRo return e => Json.Query(e.PublishIn); } - public static void ConfigureCrdt(CrdtConfig config, bool addRemoteResourceEntity = true) + public static void ConfigureCrdt(HarmonyConfig config, bool addRemoteResourceEntity = true) { config.EnableProjectedTables = true; config.ObjectTypeListBuilder @@ -394,26 +392,47 @@ public static void ConfigureCrdt(CrdtConfig config, bool addRemoteResourceEntity // you must add an instance of it to UseChangesTests.GetAllChanges() ; - config.JsonSerializerOptions.TypeInfoResolver = - (config.JsonSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver()) - .WithAddedModifier(Json.ExampleSentenceTranslationModifier); + // Attach the legacy ExampleSentence translation modifier via Harmony's deferred JSON hook. + // Do NOT read config.JsonSerializerOptions directly here: SIL.Harmony now freezes the + // ChangeTypeListBuilder the first time the options are built, which would happen before the + // AddRemoteResourceEntity call below (and AddCrdtRemoteResources in DI) and throw + // "ChangeTypeListBuilder is frozen". ConfigureJsonOptions defers the tweak until options are built. + config.ConfigureJsonOptions(options => + { + // Append to Harmony's existing resolver rather than replacing it (see ConfigureJsonOptions docs). + if (options.TypeInfoResolver is DefaultJsonTypeInfoResolver resolver) + resolver.Modifiers.Add(Json.ExampleSentenceTranslationModifier); + else + options.TypeInfoResolver = (options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver()) + .WithAddedModifier(Json.ExampleSentenceTranslationModifier); + }); if (addRemoteResourceEntity) config.AddRemoteResourceEntity(); } + /// + /// The registered CRDT change types together with their serialized $type discriminators, straight + /// from Harmony's registration. Prefer this over when you need the + /// discriminator, so it can't drift from what the serializer actually writes. + /// + public static IReadOnlyList AllRegisteredChanges() + { + var harmonyConfig = new HarmonyConfig(); + ConfigureCrdt(harmonyConfig); + return harmonyConfig.ChangeTypes; + } + public static IEnumerable AllChangeTypes() { - var crdtConfig = new CrdtConfig(); - ConfigureCrdt(crdtConfig); - return crdtConfig.ChangeTypes; + return AllRegisteredChanges().Select(t => t.Type); } public static IEnumerable AllObjectTypes() { - var crdtConfig = new CrdtConfig(); - ConfigureCrdt(crdtConfig); - return crdtConfig.ObjectTypes; + var harmonyConfig = new HarmonyConfig(); + ConfigureCrdt(harmonyConfig); + return harmonyConfig.ObjectTypes; } private static IList DeserializeTranslations(string json) diff --git a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs index 926ee24564..62060ca70e 100644 --- a/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs +++ b/backend/FwLite/LcmCrdt/MediaServer/LcmMediaService.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Collections.Concurrent; using System.Net; using System.Net.Mime; @@ -16,7 +17,7 @@ namespace LcmCrdt.MediaServer; public class LcmMediaService( ResourceService resourceService, CurrentProjectService currentProjectService, - IOptions options, + IOptions options, IRefitHttpServiceFactory refitFactory, IServerHttpClientProvider httpClientProvider, ILogger logger @@ -189,7 +190,7 @@ async Task IRemoteResourceService.DownloadResou public string ProjectResourceCachePath => ProjectCachePath(currentProjectService.Project, options.Value); - public static string ProjectCachePath(CrdtProject project, CrdtConfig options) + public static string ProjectCachePath(CrdtProject project, HarmonyConfig options) { return Path.Combine(options.LocalResourceCachePath, project.Name); } diff --git a/backend/FwLite/LcmCrdt/SnapshotAtCommitService.cs b/backend/FwLite/LcmCrdt/SnapshotAtCommitService.cs index ddf2ec2f47..f86fe701b3 100644 --- a/backend/FwLite/LcmCrdt/SnapshotAtCommitService.cs +++ b/backend/FwLite/LcmCrdt/SnapshotAtCommitService.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Diagnostics; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -14,7 +15,7 @@ namespace LcmCrdt; public class SnapshotAtCommitService( IServiceProvider serviceProvider, - IOptions crdtConfig, + IOptions harmonyConfig, CurrentProjectService currentProjectService, ILogger logger, ICrdtDbContextFactory crdtDbContextFactory) @@ -51,7 +52,7 @@ public class SnapshotAtCommitService( var optionsBuilder = new DbContextOptionsBuilder() .UseSqlite($"Data Source={forkDbPath}"); LcmCrdtKernel.ConfigureDbOptions(serviceScope.ServiceProvider, optionsBuilder); - await using var forkDbContext = new LcmCrdtDbContext(optionsBuilder.Options, crdtConfig); + await using var forkDbContext = new LcmCrdtDbContext(optionsBuilder.Options, harmonyConfig); var deleted = await DeleteCommitsAfter(forkDbContext, commit, preserveAllFieldWorksCommits); logger.LogInformation("Deleted {Deleted} commits after {CommitId}", deleted, commitId); diff --git a/backend/FwLite/LcmDebugger/FakeSyncSource.cs b/backend/FwLite/LcmDebugger/FakeSyncSource.cs index f488e2a263..b0ab3fb4d0 100644 --- a/backend/FwLite/LcmDebugger/FakeSyncSource.cs +++ b/backend/FwLite/LcmDebugger/FakeSyncSource.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using LcmCrdt; @@ -42,12 +43,11 @@ public static FakeSyncSource FromJsonFile(string path, JsonSerializerOptions? op { if (options is null) { - var config = new CrdtConfig(); + var config = new HarmonyConfig(); LcmCrdtKernel.ConfigureCrdt(config); - options = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - TypeInfoResolver = config.MakeLcmCrdtExternalJsonTypeResolver(), - }; + // Full external options (resolver + Harmony's IChange converter); the resolver alone can't + // deserialize the ChangeEntity fields in the captured sync payload. + options = config.MakeLcmCrdtExternalJsonOptions(); } using var file = File.OpenRead(path); diff --git a/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs b/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs index 2737455254..74814ff503 100644 --- a/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs +++ b/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs @@ -1,3 +1,4 @@ +using SIL.Harmony.Config; using FwDataMiniLcmBridge; using FwDataMiniLcmBridge.Api; using FwDataMiniLcmBridge.Tests.Fixtures; @@ -246,7 +247,7 @@ private ServiceProvider BuildServiceProvider( // SyncWorker only passes this through to MediaFileService. services.AddSingleton(_ => - new Mock(MockBehavior.Loose, null!, null!, Options.Create(new SIL.Harmony.CrdtConfig()), null!, null!, NullLogger.Instance).Object); + new Mock(MockBehavior.Loose, null!, null!, Options.Create(new HarmonyConfig()), null!, null!, NullLogger.Instance).Object); var syncService = new Mock( MockBehavior.Strict, @@ -274,7 +275,7 @@ private ServiceProvider BuildServiceProvider( services.AddSingleton(syncService.Object); - var snapshotService = new Mock(MockBehavior.Strict, Options.Create(new SIL.Harmony.CrdtConfig())); + var snapshotService = new Mock(MockBehavior.Strict, Options.Create(new HarmonyConfig())); snapshotService .Setup(s => s.GetProjectSnapshot(It.IsAny())) .Callback(() => Steps.Add(GetSnapshot))