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