diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 1d6c2bbdef..3104e6d15b 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -64,7 +64,37 @@ jobs: run: task fw-lite:has-pending-model-changes -- --no-build - name: Dotnet test - run: dotnet test FwLiteOnly.slnf --logger GitHubActions --no-build -p:BuildAndroid=false + # Benchmarks run in a separate Release-mode job — see `benchmark` below. + run: dotnet test FwLiteOnly.slnf --logger GitHubActions --no-build -p:BuildAndroid=false --filter "Category!=Benchmark" + + benchmark: + name: Run FW Lite sync benchmarks + # BenchmarkDotNet timings are only meaningful in Release; this job runs the FwLiteProjectSync.Tests + # project in Release with --filter "Category=Benchmark" so threshold assertions actually fire. + timeout-minutes: 40 + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Dotnet build (Release) + run: dotnet build backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj -c Release + + - name: Dotnet test (benchmarks) + run: dotnet test backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj -c Release --logger GitHubActions --no-build --filter "Category=Benchmark" + + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: '**/BenchmarkDotNet.Artifacts/**' + if-no-files-found: warn frontend: runs-on: ubuntu-latest diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index bf493b9a70..ceefa71268 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs b/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs new file mode 100644 index 0000000000..7a9eb7aae4 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/BenchmarkSupport.cs @@ -0,0 +1,63 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; +using FluentAssertions.Execution; +using Xunit.Abstractions; + +namespace FwLiteProjectSync.Tests; + +internal static class BenchmarkSupport +{ + /// + /// Standard config for sync benchmarks: in-process toolchain, one warmup + four measurement + /// iterations (Monitoring strategy), and a logger that pipes the BDN summary table into + /// xUnit's test output. + /// + /// + /// The in-process toolchain is what lets a benchmark class read a static field set by its + /// xUnit driver (see FirstSyncBench.Fixture) — a separate-process toolchain would + /// fork a clean AppDomain and lose that reference. + /// + /// The default timeout is 5 min, which is too short + /// for multi-iteration sync benchmarks (e.g. delete-heavy alone is ~80s/iter and full-import + /// setup adds ~50s/iter, so 5 iterations need ~11 min). 30 min covers the worst case with + /// headroom. + /// + /// Monitoring + WarmupCount=1 means iteration 1 absorbs JIT + EF model build + first-touch + /// file cache and is discarded; iterations 2-5 are measured. ColdStart would skip warmup + /// entirely; for these slow ops the JIT/model-build noise in iteration 1 is large enough + /// that one discarded warmup tightens StdDev meaningfully without changing total CI time. + /// + public static IConfig ConfigFor(ITestOutputHelper output) + { + var toolchain = new InProcessNoEmitToolchain(TimeSpan.FromMinutes(30), logOutput: false); + return ManualConfig.CreateEmpty() + .AddJob(Job.Default + .WithStrategy(RunStrategy.Monitoring) + .WithWarmupCount(1) + .WithIterationCount(4) + .WithToolchain(toolchain)) + .AddExporter(JsonExporter.FullCompressed) + .AddColumnProvider(DefaultColumnProviders.Instance) + .AddLogger(new XUnitBenchmarkLogger(output)); + } + + /// + /// Asserts the benchmark run produced usable measurements. Call inside an + /// so per-report threshold failures still surface. + /// + public static void AssertRunWasSuccessful(Summary summary) + { + summary.HasCriticalValidationErrors.Should().BeFalse("BenchmarkDotNet reported critical validation errors"); + summary.Reports.Should().NotBeEmpty("BenchmarkDotNet produced no reports"); + foreach (var report in summary.Reports) + { + report.Success.Should().BeTrue($"benchmark {report.BenchmarkCase.DisplayInfo} should have completed without error"); + report.ResultStatistics.Should().NotBeNull($"benchmark {report.BenchmarkCase.DisplayInfo} should have produced statistics"); + } + } +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3Collection.cs b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3Collection.cs new file mode 100644 index 0000000000..7226c6629b --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/Fixtures/Sena3Collection.cs @@ -0,0 +1,13 @@ +namespace FwLiteProjectSync.Tests.Fixtures; + +/// +/// Groups every test class that uses into a single xUnit +/// collection so they share one fixture instance and run serially. Without this, parallel +/// classes each spin up their own fixture and race on the shared ./Sena3Fixture/ +/// folder during . +/// +[CollectionDefinition(Name)] +public class Sena3Collection : ICollectionFixture +{ + public const string Name = nameof(Sena3Collection); +} diff --git a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj index d234fd79a9..13a323d3fa 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj +++ b/backend/FwLite/FwLiteProjectSync.Tests/FwLiteProjectSync.Tests.csproj @@ -5,6 +5,7 @@ $(MSBuildProjectDirectory) + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 1b6a71f1ea..c3f1aa6b07 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -13,7 +13,8 @@ namespace FwLiteProjectSync.Tests; [Trait("Category", "Integration")] -public class Sena3SyncTests : IClassFixture, IAsyncLifetime +[Collection(Sena3Collection.Name)] +public class Sena3SyncTests : IAsyncLifetime { private readonly Sena3Fixture _fixture; private CrdtFwdataProjectSyncService _syncService = null!; diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs new file mode 100644 index 0000000000..cc16d3558a --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncBenchmark.cs @@ -0,0 +1,93 @@ +using BenchmarkDotNet.Attributes; +#if !DEBUG +using BenchmarkDotNet.Running; +using FluentAssertions.Execution; +#endif +using FwLiteProjectSync.Tests.Fixtures; +using LexCore.Sync; +using Microsoft.Extensions.DependencyInjection; +using MiniLcm; +using Xunit.Abstractions; + +namespace FwLiteProjectSync.Tests; + +/// +/// Times a first sync of Sena-3 from an empty CRDT (sync, not import — we save an empty +/// snapshot first so the path runs as a real sync). +/// +[Trait("Category", "Integration")] +[Trait("Category", "Benchmark")] +[Collection(Sena3Collection.Name)] +public class SyncBenchmark(Sena3Fixture fixture, ITestOutputHelper output) +{ + [Fact] + public async Task First_Sync_Sena3() + { + FirstSyncBench.Fixture = fixture; +#if DEBUG + // Debug timings are unreliable (no JIT optimizations + BDN harness flags Debug builds). + // Run once for code-path coverage; thresholds enforced in Release only (CI benchmark job). + output.WriteLine("Debug build: running once for coverage; threshold enforced in Release only."); + var bench = new FirstSyncBench(); + bench.IterationSetup(); + try { _ = await bench.SyncFromEmpty(); } + finally { bench.IterationCleanup(); } +#else + using var scope = new AssertionScope(); + var summary = BenchmarkRunner.Run(BenchmarkSupport.ConfigFor(output)); + BenchmarkSupport.AssertRunWasSuccessful(summary); + + var report = summary.Reports.Single(); + var meanSeconds = report.ResultStatistics!.Mean / 1_000_000_000.0; + output.WriteLine($"first-sync mean = {meanSeconds:F2}s (bound={FirstSyncBench.ThresholdSeconds:F2}s)"); + + meanSeconds.Should().BeLessThan(FirstSyncBench.ThresholdSeconds, + $"first-sync should not regress past its threshold — see {nameof(FirstSyncBench)}.{nameof(FirstSyncBench.ThresholdSeconds)}"); +#endif + } +} + +// BenchmarkDotNet doesn't support async [IterationSetup]/[IterationCleanup] signatures, +// so GetAwaiter().GetResult() below is intentional. +#pragma warning disable VSTHRD002 + +public class FirstSyncBench +{ + // CI baseline (no index): mean 52.34s, StdDev 0.27s (low variance) => 57s (~10%) + // CI with commits order index: mean 44.35s, StdDev 1.10s (med variance) => 51s (~15%) + public const double ThresholdSeconds = 51.0; + + internal static Sena3Fixture Fixture = null!; + + private TestProject _project = null!; + private ProjectSnapshot _projectSnapshot = null!; + private CrdtFwdataProjectSyncService _syncService = null!; + + [IterationSetup] + public void IterationSetup() + { + _project = Fixture.SetupProjects().GetAwaiter().GetResult(); + _syncService = _project.Services.GetRequiredService(); + + // Save an empty snapshot so the first sync runs as Sync, not Import. + ProjectSnapshotService.SaveProjectSnapshot(_project.FwDataProject, ProjectSnapshot.Empty) + .GetAwaiter().GetResult(); + _projectSnapshot = _project.Services.GetRequiredService() + .GetProjectSnapshot(_project.FwDataProject).GetAwaiter().GetResult() + ?? throw new InvalidOperationException("Expected snapshot to exist after saving"); + } + + [Benchmark] + public async Task SyncFromEmpty() + { + return await _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot); + } + + [IterationCleanup] + public void IterationCleanup() + { + _project?.Dispose(); + _project = null!; + } +} +#pragma warning restore VSTHRD002 diff --git a/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs new file mode 100644 index 0000000000..a66e905051 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/SyncMutationBenchmark.cs @@ -0,0 +1,255 @@ +using BenchmarkDotNet.Attributes; +#if !DEBUG +using BenchmarkDotNet.Running; +using FluentAssertions.Execution; +#endif +using FwLiteProjectSync.Tests.Fixtures; +using LexCore.Sync; +using Microsoft.Extensions.DependencyInjection; +using MiniLcm; +using MiniLcm.Models; +using MiniLcm.SyncHelpers; +using Soenneker.Utils.AutoBogus; +using Xunit.Abstractions; + +namespace FwLiteProjectSync.Tests; + +/// +/// Times sync after applying a profile-specific batch of mutations to the FwData side of +/// a fully-imported Sena-3 project. Each profile stresses a different change kind. +/// +[Trait("Category", "Integration")] +[Trait("Category", "Benchmark")] +[Collection(Sena3Collection.Name)] +public class SyncMutationBenchmark(Sena3Fixture fixture, ITestOutputHelper output) +{ + [Fact] + public async Task Sync_AfterMutations_Sena3() + { + MutationSyncBench.Fixture = fixture; +#if DEBUG + // Debug timings are unreliable (no JIT optimizations + BDN harness flags Debug builds). + // Run each profile once for code-path coverage; thresholds enforced in Release only (CI benchmark job). + output.WriteLine("Debug build: running each profile once for coverage; thresholds enforced in Release only."); + var bench = new MutationSyncBench(); + foreach (var profile in MutationSyncBench.ThresholdSecondsByProfile.Keys) + { + bench.Profile = profile; + bench.IterationSetup(); + try { _ = await bench.SyncAfterMutations(); } + finally { bench.IterationCleanup(); } + } +#else + using var scope = new AssertionScope(); + var summary = BenchmarkRunner.Run(BenchmarkSupport.ConfigFor(output)); + BenchmarkSupport.AssertRunWasSuccessful(summary); + + foreach (var report in summary.Reports) + { + // ResultStatistics is guaranteed non-null by AssertRunWasSuccessful, but the scope + // keeps going on failure so we still need a defensive null-check before dereferencing. + if (report.ResultStatistics is null) continue; + + var profile = report.BenchmarkCase.Parameters["Profile"]?.ToString() + ?? throw new InvalidOperationException("Expected Profile parameter on benchmark case"); + var meanSeconds = report.ResultStatistics.Mean / 1_000_000_000.0; + var bound = MutationSyncBench.ThresholdSecondsByProfile[profile]; + output.WriteLine($"{profile} mean = {meanSeconds:F2}s (bound={bound:F2}s)"); + + meanSeconds.Should().BeLessThan(bound, + $"profile {profile} should not regress past its threshold — see {nameof(MutationSyncBench)}.{nameof(MutationSyncBench.ThresholdSecondsByProfile)}"); + } +#endif + } +} + +#pragma warning disable VSTHRD002 + +public class MutationSyncBench +{ + public static readonly IReadOnlyDictionary ThresholdSecondsByProfile = new Dictionary + { + // CI baseline (no index): mean 52.40s, StdDev 1.51s (med variance) => 60s (~15%) + // CI with commits order index: mean 50.49s, StdDev 0.87s (low variance) => 55s (~10%) + ["component-heavy"] = 55.0, + // CI baseline (no index): mean 87.02s, StdDev 3.85s (hi variance) => 100s (~15%) + // CI with commits order index: mean 87.92s, StdDev 1.42s (med variance) => 100s (~15%) + ["delete-heavy"] = 100.0, + // CI baseline (no index): mean 32.99s, StdDev 0.77s (low variance) => 36s (~10%) + // CI with commits order index: mean 32.95s, StdDev 1.08s (low variance) => 36s (~10%) + ["mixed-realistic"] = 36.0, + // CI baseline (no index): mean 4.52s, StdDev 0.08s (low variance) => 5s (~10%) + // CI with commits order index: mean 3.53s, StdDev 0.10s (low variance) => 4s (~10%) + ["patch-heavy"] = 4.0, + // CI baseline (no index): mean 0.69s, StdDev 0.08s (low variance) => 2s (super fast, so meh) + // CI with commits order index: mean 0.57s, StdDev 0.05s (low variance) => 1.5s (super fast, so meh) + ["reorder-heavy"] = 1.5, + }; + + public static IEnumerable Profiles => ThresholdSecondsByProfile.Keys; + + [ParamsSource(nameof(Profiles))] + public string Profile { get; set; } = ""; + + private const int MutationCount = 400; + + private static readonly AutoFaker AutoFaker = new(); + + internal static Sena3Fixture Fixture = null!; + + private TestProject _project = null!; + private ProjectSnapshot _projectSnapshot = null!; + private CrdtFwdataProjectSyncService _syncService = null!; + + [IterationSetup] + public void IterationSetup() + { + _project = Fixture.SetupProjects().GetAwaiter().GetResult(); + var services = _project.Services; + _syncService = services.GetRequiredService(); + var snapshotService = services.GetRequiredService(); + + _syncService.Import(_project.CrdtApi, _project.FwDataApi).GetAwaiter().GetResult(); + snapshotService.RegenerateProjectSnapshot(_project.CrdtApi, _project.FwDataProject, keepBackup: false) + .GetAwaiter().GetResult(); + _projectSnapshot = snapshotService.GetProjectSnapshot(_project.FwDataProject).GetAwaiter().GetResult() + ?? throw new InvalidOperationException("Expected snapshot to exist after regeneration"); + + ApplyProfile(Profile, _project.FwDataApi, MutationCount).GetAwaiter().GetResult(); + } + + [Benchmark] + public async Task SyncAfterMutations() + { + return await _syncService.Sync(_project.CrdtApi, _project.FwDataApi, _projectSnapshot); + } + + [IterationCleanup] + public void IterationCleanup() + { + _project?.Dispose(); + _project = null!; + } + + private static async Task ApplyProfile(string profile, IMiniLcmApi api, int count) + { + var allEntries = await api.GetAllEntries().ToListAsync(); + var shuffled = AutoFaker.Faker.Random.Shuffle(allEntries).ToList(); + + switch (profile) + { + case "delete-heavy": + await DeleteEntries(api, shuffled.Take(count)); + break; + case "patch-heavy": + await PatchLexemes(api, shuffled.Take(count)); + break; + case "reorder-heavy": + await ReorderSenses(api, shuffled.Take(count)); + break; + case "component-heavy": + await AndCompleteFormComponents(api, shuffled, count); + break; + case "mixed-realistic": + await MixedRealistic(api, shuffled, count); + break; + default: + throw new ArgumentException($"Unknown profile: {profile}", nameof(profile)); + } + } + + // Disjoint slices so each kind of mutation operates on a different set of entries. + private static async Task MixedRealistic(IMiniLcmApi api, List entries, int totalCount) + { + var per = totalCount / 4; + await DeleteEntries(api, entries.Take(per)); + await PatchLexemes(api, entries.Skip(per).Take(per)); + await ReorderSenses(api, entries.Skip(per * 2).Take(per)); + await AndCompleteFormComponents(api, [.. entries.Skip(per * 3)], per); + } + + private static async Task DeleteEntries(IMiniLcmApi api, IEnumerable entries) + { + foreach (var entry in entries) + await api.DeleteEntry(entry.Id); + } + + private static async Task PatchLexemes(IMiniLcmApi api, IEnumerable entries) + { + foreach (var entry in entries) + { + var after = entry.Copy(); + // Mutate the first existing lexeme WS, or fall back to citation. + if (after.LexemeForm.Values.Count > 0) + { + var (wsId, val) = after.LexemeForm.Values.First(); + after.LexemeForm[wsId] = val + "_mut"; + } + else if (after.CitationForm.Values.Count > 0) + { + var (wsId, val) = after.CitationForm.Values.First(); + after.CitationForm[wsId] = val + "_mut"; + } + else + { + continue; + } + await api.UpdateEntry(entry, after); + } + } + + private static async Task ReorderSenses(IMiniLcmApi api, IEnumerable entries) + { + // Only entries with 2+ senses can be reordered; move the first sense to the end. + foreach (var entry in entries.Where(e => e.Senses.Count >= 2)) + { + var first = entry.Senses[0]; + var last = entry.Senses[^1]; + if (first.Id == last.Id) continue; + await api.MoveSense(entry.Id, first.Id, new BetweenPosition(last.Id, null)); + } + } + + private static async Task AndCompleteFormComponents(IMiniLcmApi api, List pool, int targetCount) + { + var attempted = new HashSet<(Guid, Guid)>( + pool.SelectMany(e => e.Components.Select(c => (c.ComplexFormEntryId, c.ComponentEntryId)))); + + var applied = 0; + var attempts = 0; + var maxAttempts = targetCount * 10; + while (applied < targetCount && attempts < maxAttempts) + { + attempts++; + var cfIdx = AutoFaker.Faker.Random.Int(0, pool.Count - 1); + var compIdx = AutoFaker.Faker.Random.Int(0, pool.Count - 1); + if (cfIdx == compIdx) continue; + var cf = pool[cfIdx]; + var comp = pool[compIdx]; + if (!attempted.Add((cf.Id, comp.Id))) continue; + + try + { + await api.CreateComplexFormComponent(new ComplexFormComponent + { + Id = Guid.NewGuid(), + ComplexFormEntryId = cf.Id, + ComponentEntryId = comp.Id, + ComponentSenseId = comp.Senses.Any() ? AutoFaker.Faker.PickRandom(comp.Senses).Id : null, + }); + applied++; + } + catch + { + // Some components are invalid. Just skip and try another. + } + } + + if (applied < targetCount) + { + throw new InvalidOperationException( + $"Failed to apply {targetCount} complex form links after {attempts} attempts; only applied {applied}"); + } + } +} +#pragma warning restore VSTHRD002 diff --git a/backend/FwLite/FwLiteProjectSync.Tests/XUnitBenchmarkLogger.cs b/backend/FwLite/FwLiteProjectSync.Tests/XUnitBenchmarkLogger.cs new file mode 100644 index 0000000000..3657394513 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync.Tests/XUnitBenchmarkLogger.cs @@ -0,0 +1,40 @@ +using System.Text; +using BenchmarkDotNet.Loggers; +using Xunit.Abstractions; + +namespace FwLiteProjectSync.Tests; + +/// +/// Bridges BenchmarkDotNet's line-buffered logger calls to xUnit's . +/// BDN writes one line via several calls followed by ; +/// we accumulate fragments in and flush on each line break. +/// +internal sealed class XUnitBenchmarkLogger(ITestOutputHelper output) : ILogger +{ + public string Id => nameof(XUnitBenchmarkLogger); + public int Priority => 0; + private readonly StringBuilder _line = new(); + + public void Write(LogKind logKind, string text) + { + _line.Append(text); + } + + public void WriteLine() + { + output.WriteLine(_line.ToString()); + _line.Clear(); + } + + public void WriteLine(LogKind logKind, string text) + { + _line.Append(text); + WriteLine(); + } + + public void Flush() + { + if (_line.Length == 0) return; + WriteLine(); + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.Designer.cs b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.Designer.cs new file mode 100644 index 0000000000..b1a7a659e1 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.Designer.cs @@ -0,0 +1,783 @@ +// +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20260506150734_AddCommitsOrderIndex")] + partial class AddCommitsOrderIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Headword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EntrySearchRecord", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Analysis") + .HasColumnType("jsonb"); + + b.Property("Base") + .HasColumnType("INTEGER"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExampleFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SenseFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Vernacular") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("CustomView"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MorphType") + .HasColumnType("INTEGER"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("jsonb"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translations") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty>("HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.LocalResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LocalResource"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("RemoteResource"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CustomView", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("SIL.Harmony.Resource.RemoteResource", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs new file mode 100644 index 0000000000..0c19509e2d --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260506150734_AddCommitsOrderIndex.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + // Backs harmony's default commit ordering. Hand-written because the index spans a + // ComplexProperty + regular property (efcore#11336). Perhaps move to harmony's fluent API + // via EFCore.ComplexIndexes when we're on .NET 10. + public partial class AddCommitsOrderIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Commits_DateTime_Counter_Id", + table: "Commits", + columns: ["DateTime", "Counter", "Id"], + descending: [true, true, true]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: "IX_Commits_DateTime_Counter_Id", table: "Commits"); + } + } +}