diff --git a/.claude/agents/test-auditor.md b/.claude/agents/test-auditor.md index d920ead11a..f38b3bc368 100644 --- a/.claude/agents/test-auditor.md +++ b/.claude/agents/test-auditor.md @@ -18,7 +18,7 @@ cover the changes. - **`Enum.GetValues()`** instead of casting `int`s. - **New enum values** (e.g. `RegressionVersion`) need parallel `[InlineData]` rows. -- **Reproduce sync bugs with `DryRunMiniLcmApi` before fixing** (PR #2252). +- **Reproduce sync bugs with a dry-run sync (`SyncDryRun`/`ImportDryRun`) before fixing** (PR #2252). - **Test cleanup.** Unique filenames derived from `code` variable so reruns don't trip on prior state (PR #2219). - **`[Skip]` / `[SkipWhen]`** new additions: ask for justification. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index bc5c6d4a14..081083c0cb 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -39,7 +39,7 @@ To build against local Harmony source (e.g. when developing the CRDT substrate), - **Nullable**: Enabled globally, `Nullable` warnings are errors - **Implicit usings**: Enabled - **Target framework**: net10.0 (unless platform-specific) -- **Async**: Use `async/await`, not `.Result` or `.Wait()` +- **Async**: Use `async/await`, not `.Result` or `.Wait()`. Prefer `return await Foo()` over returning the task directly — awaiting keeps this method on the stack trace when the inner call throws. Only drop the `await` (return the task) as a hot-path micro-optimization ([#2435](https://github.com/sillsdev/languageforge-lexbox/pull/2435#discussion_r3584331713)) - **Records**: Prefer for DTOs and immutable data ## Key Patterns diff --git a/backend/FwHeadless/AGENTS.md b/backend/FwHeadless/AGENTS.md index 5ea14a1eac..16d9c21c5d 100644 --- a/backend/FwHeadless/AGENTS.md +++ b/backend/FwHeadless/AGENTS.md @@ -249,7 +249,7 @@ dotnet test ../FwLite/FwLiteProjectSync.Tests ## Debugging Tips 1. **Enable detailed logging** in `appsettings.Development.json` -2. **Use `DryRunMiniLcmApi`** to see what sync would do without applying +2. **Use a dry-run sync** (`SyncDryRun`/`ImportDryRun`) to see what sync would do without applying 3. **Check ProjectSnapshot** - if it's wrong, sync will be wrong 4. **Compare CRDT and FwData states** manually when debugging 5. **Mercurial log** (`hg log -v`) shows commit history diff --git a/backend/FwLite/AGENTS.md b/backend/FwLite/AGENTS.md index 0e7c2e4b52..2bc1a1d26a 100644 --- a/backend/FwLite/AGENTS.md +++ b/backend/FwLite/AGENTS.md @@ -330,7 +330,7 @@ This is major work. Follow the pattern of `Sense`: ### "Fix a sync bug" 1. Reproduce with a test in `FwLiteProjectSync.Tests/` -2. Use `DryRunMiniLcmApi` to see what changes would be made +2. Use a dry-run sync (`SyncDryRun`/`ImportDryRun`, which record via `RecordingMiniLcmApi`) to see what changes would be made 3. Debug through `CrdtFwdataProjectSyncService.Sync()` 4. Check `ProjectSnapshot` handling diff --git a/backend/FwLite/FwLiteProjectSync.Tests/CrdtRepairTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/CrdtRepairTests.cs index 751ec82890..436190a1e6 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/CrdtRepairTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/CrdtRepairTests.cs @@ -109,6 +109,25 @@ public async Task CrdtEntryMissingTranslationId_NoChanges_FullSync() updatedSnapshotEntry.SingleTranslation().Id.Should().Be(fwTranslationId); } + [Fact] + public async Task CrdtEntryMissingTranslationId_DryRunSync_LeavesRealCrdtUntouched() + { + // arrange + var (fwEntry, crdtEntry, _) = await CreateSyncedEntryMissingTranslationId(); + var crdtTranslationIdBefore = crdtEntry.SingleTranslation().Id; + var fwTranslationId = fwEntry.SingleTranslation().Id; + crdtTranslationIdBefore.Should().NotBe(fwTranslationId, "the repair hasn't run yet"); + + // act - a dry run repairs translation IDs on the throwaway copy, never the real project + var projectSnapshot = await GetSnapshot(); + await SyncService.SyncDryRun(CrdtApi, FwDataApi, projectSnapshot); + + // assert - the real crdt translation ID is unchanged (a real sync would have written the fwdata ID) + var crdtEntryAfter = await CrdtApi.GetEntry(crdtEntry.Id); + crdtEntryAfter.Should().NotBeNull(); + crdtEntryAfter.SingleTranslation().Id.Should().Be(crdtTranslationIdBefore); + } + [Fact] public async Task CrdtEntryMissingTranslationId_FwTranslationChanged_FullSync() { diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 6654196a7c..80a20b731a 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -4,7 +4,6 @@ using FwDataMiniLcmBridge.Api; using FwLiteProjectSync.Tests.Fixtures; using LcmCrdt; -using MiniLcm.Import; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -68,21 +67,14 @@ private void ShouldAllBeEquivalentTo(Dictionary crdtEntries, Dictio } } - //by default the first sync is an import, this will skip that so that the sync will actually sync data - private async Task CreateAndSaveMinimalSnapshot(bool withWritingSystems = false) + private async Task PrepareToSync() { - var snapshot = ProjectSnapshot.Empty; - if (withWritingSystems) snapshot = snapshot with { WritingSystems = await _fwDataApi.GetWritingSystems() }; + // crdt needs a default vernacular WS in order to be readable/syncable + var fwdataWritingSystems = await _fwDataApi.GetWritingSystems(); + await _crdtApi.CreateWritingSystem(fwdataWritingSystems.Vernacular.First()); - //saving the snapshot will try to read the Id but it will be empty when coming from FwData - foreach (var ws in snapshot.WritingSystems.Analysis) - { - if (ws.MaybeId is null) ws.Id = Guid.NewGuid(); - } - foreach (var ws in snapshot.WritingSystems.Vernacular) - { - if (ws.MaybeId is null) ws.Id = Guid.NewGuid(); - } + //by default the first sync is an import, this will skip that so that the sync will actually sync data + var snapshot = await _crdtApi.TakeProjectSnapshot(); await ProjectSnapshotService.SaveProjectSnapshot(_fwDataApi.Project, snapshot); return await _snapshotService.GetProjectSnapshot(_fwDataApi.Project) ?? throw new InvalidOperationException("Expected snapshot to exist after saving"); @@ -101,10 +93,18 @@ private async Task WorkaroundMissingWritingSystems() public async Task DryRunImport_MakesNoChanges() { await WorkaroundMissingWritingSystems(); - _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + + var crdtSnapshotBefore = await _crdtApi.TakeProjectSnapshot(); + crdtSnapshotBefore.Entries.Should().BeEmpty(); + var fwdataSnapshotBefore = await _fwDataApi.TakeProjectSnapshot(); + await _syncService.ImportDryRun(_crdtApi, _fwDataApi); - //should still be empty - _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + + var crdtSnapshotAfter = await _crdtApi.TakeProjectSnapshot(); + var fwdataSnapshotAfter = await _fwDataApi.TakeProjectSnapshot(); + + crdtSnapshotAfter.Should().BeEquivalentTo(crdtSnapshotBefore); + fwdataSnapshotAfter.Should().BeEquivalentTo(fwdataSnapshotBefore); } [Fact] @@ -120,12 +120,19 @@ public async Task DryRunImport_MakesTheSameChangesAsImport() [Trait("Category", "Integration")] public async Task DryRunSync_MakesNoChanges() { - var projectSnapshot = await CreateAndSaveMinimalSnapshot(); - await WorkaroundMissingWritingSystems(); - _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + var projectSnapshot = await PrepareToSync(); + + var crdtSnapshotBefore = await _crdtApi.TakeProjectSnapshot(); + crdtSnapshotBefore.Entries.Should().BeEmpty(); + var fwdataSnapshotBefore = await _fwDataApi.TakeProjectSnapshot(); + await _syncService.SyncDryRun(_crdtApi, _fwDataApi, projectSnapshot); - //should still be empty - _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + + var crdtSnapshotAfter = await _crdtApi.TakeProjectSnapshot(); + var fwdataSnapshotAfter = await _fwDataApi.TakeProjectSnapshot(); + + crdtSnapshotAfter.Should().BeEquivalentTo(crdtSnapshotBefore); + fwdataSnapshotAfter.Should().BeEquivalentTo(fwdataSnapshotBefore); } [Fact] @@ -133,15 +140,13 @@ public async Task DryRunSync_MakesNoChanges() [Trait("Category", "Integration")] public async Task DryRunSync_MakesTheSameChangesAsSync() { - //syncing requires querying entries, which fails if there are no writing systems, so we import those first - await _project.Services.GetRequiredService() - .ImportWritingSystems(_crdtApi, await _fwDataApi.GetWritingSystems()); - var projectSnapshot = await CreateAndSaveMinimalSnapshot(true); + var projectSnapshot = await PrepareToSync(); + var dryRunSyncResult = await _syncService.SyncDryRun(_crdtApi, _fwDataApi, projectSnapshot); var syncResult = await _syncService.Sync(_crdtApi, _fwDataApi, projectSnapshot); dryRunSyncResult.CrdtChanges.Should().Be(syncResult.CrdtChanges); - //can't test fwdata changes as they will not work correctly since the sync code expects Crdts to contain data from FWData - //this throws off the algorithm and it will try to delete everything in fwdata since there's no data in the crdt since it was a dry run + // the CRDT side ran against a throwaway copy, so a dry run sync back to fwdata also reflects a real sync + dryRunSyncResult.FwdataChanges.Should().Be(syncResult.FwdataChanges); } [Fact] @@ -172,7 +177,8 @@ await _crdtApi.TakeProjectSnapshot() [Trait("Category", "Integration")] public async Task SyncWithoutImport_CrdtShouldMatchFwdata() { - var projectSnapshot = await CreateAndSaveMinimalSnapshot(); + var projectSnapshot = await PrepareToSync(); + var results = await _syncService.Sync(_crdtApi, _fwDataApi, projectSnapshot); results.FwdataChanges.Should().Be(0); results.CrdtChanges.Should().BeGreaterThan(_fwDataApi.EntryCount); diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index 588803caba..e9d9d352ca 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -11,13 +11,14 @@ namespace FwLiteProjectSync; public class CrdtFwdataProjectSyncService(MiniLcmImport miniLcmImport, ILogger logger, - MiniLcmApiValidationWrapperFactory validationWrapperFactory) + MiniLcmApiValidationWrapperFactory validationWrapperFactory, + CrdtProjectsService crdtProjectsService) { public record DryRunSyncResult( int CrdtChanges, int FwdataChanges, - List CrdtDryRunRecords, - List FwDataDryRunRecords) : SyncResult(CrdtChanges, FwdataChanges); + List CrdtDryRunRecords, + List FwDataDryRunRecords) : SyncResult(CrdtChanges, FwdataChanges); public async Task SyncDryRun(IMiniLcmApi crdtApi, FwDataMiniLcmApi fwdataApi, ProjectSnapshot projectSnapshot) { @@ -61,6 +62,15 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc throw new InvalidOperationException("Project sync state does not match presence of snapshot."); } + await using var crdtCopy = dryRun ? await crdtProjectsService.OpenTempProjectCopy(crdt.Project) : null; + if (dryRun) + { + // Point the whole CRDT side at the copy, crdt included — the translation-id repair below writes + // through crdt, so rebinding it keeps that write off the real project and lets the copy read it back. + crdt = crdtCopy?.Api ?? throw new InvalidOperationException("crdtCopy must be defined in a dryRun"); + crdtApi = crdt; + } + // No write normalization: Data is already normalised on both sides. // No query normalization: The sync doesn't do any querying. crdtApi = validationWrapperFactory.Create(crdtApi); @@ -68,14 +78,14 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc if (dryRun) { - crdtApi = new DryRunMiniLcmApi(crdtApi); - fwdataApi = new DryRunMiniLcmApi(fwdataApi); + crdtApi = new RecordingMiniLcmApi(crdtApi); + fwdataApi = new RecordingMiniLcmApi(new WriteIgnoringMiniLcmApi(fwdataApi)); } if (projectSnapshot is not null) { // Repair any missing translation IDs before doing the full sync, so the sync doesn't have to deal with them - var syncedIdCount = await CrdtRepairs.SyncMissingTranslationIds(projectSnapshot.Entries, fwdata, crdt, dryRun); + await CrdtRepairs.SyncMissingTranslationIds(projectSnapshot.Entries, fwdata, crdt); // Patch legacy snapshots that were created before morph-type support. // After seeding, the CRDT has morph-types but the snapshot still has []. @@ -100,10 +110,10 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc return syncResult; } - LogDryRun(crdtApi, "crdt"); - LogDryRun(fwdataApi, "fwdata"); + LogRecordedRun(crdtApi, "crdt"); + LogRecordedRun(fwdataApi, "fwdata"); return new DryRunSyncResult(syncResult.CrdtChanges, syncResult.FwdataChanges, - GetDryRunRecords(crdtApi), GetDryRunRecords(fwdataApi)); + GetRunRecords(crdtApi), GetRunRecords(fwdataApi)); } private async Task ImportInternal(IMiniLcmApi crdtApi, IMiniLcmApi fwdataApi, int entryCount) @@ -145,19 +155,19 @@ private async Task SyncInternal(IMiniLcmApi crdtApi, IMiniLcmApi fwd return new SyncResult(crdtChanges, fwdataChanges); } - private void LogDryRun(IMiniLcmApi api, string type) + private void LogRecordedRun(IMiniLcmApi api, string type) { - if (api is not DryRunMiniLcmApi dryRunApi) return; - foreach (var dryRunRecord in dryRunApi.DryRunRecords) + if (api is not RecordingMiniLcmApi recorder) return; + foreach (var dryRunRecord in recorder.RunRecords) { logger.LogInformation($"Dry run {type} record: {dryRunRecord.Method} {dryRunRecord.Description}"); } - logger.LogInformation($"Dry run {type} changes: {dryRunApi.DryRunRecords.Count}"); + logger.LogInformation($"Dry run {type} changes: {recorder.RunRecords.Count}"); } - private List GetDryRunRecords(IMiniLcmApi api) + private List GetRunRecords(IMiniLcmApi api) { - return ((DryRunMiniLcmApi)api).DryRunRecords; + return ((RecordingMiniLcmApi)api).RunRecords; } } diff --git a/backend/FwLite/FwLiteProjectSync/CrdtRepairs.cs b/backend/FwLite/FwLiteProjectSync/CrdtRepairs.cs index cd93326293..ab03d12751 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtRepairs.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtRepairs.cs @@ -7,7 +7,7 @@ namespace FwLiteProjectSync; public static class CrdtRepairs { #pragma warning disable CS0618 // Type or member is obsolete - public static async Task SyncMissingTranslationIds(Entry[] snapshotEntries, FwDataMiniLcmApi fwDataApi, CrdtMiniLcmApi crdtApi, bool dryRun = false) + public static async Task SyncMissingTranslationIds(Entry[] snapshotEntries, FwDataMiniLcmApi fwDataApi, CrdtMiniLcmApi crdtApi) { using var activity = FwLiteProjectSyncActivitySource.Value.StartActivity(); // Sync any available IDs from fwdata to the snapshot and the crdt entries @@ -78,7 +78,7 @@ public static async Task SyncMissingTranslationIds(Entry[] snapshotEntries, } } - if (!dryRun && exampleSentenceIdToTranslationId.Any()) + if (exampleSentenceIdToTranslationId.Any()) { await crdtApi.SetFirstTranslationIds(exampleSentenceIdToTranslationId); } diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs deleted file mode 100644 index 61ea258c89..0000000000 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ /dev/null @@ -1,491 +0,0 @@ -using MiniLcm; -using MiniLcm.Exceptions; -using MiniLcm.Models; -using MiniLcm.SyncHelpers; - -namespace FwLiteProjectSync; - -public partial class DryRunMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi -{ - [BeaKona.AutoInterface(typeof(IMiniLcmReadApi), MemberMatch = BeaKona.MemberMatchTypes.Any)] - private readonly IMiniLcmApi _api = api; - - public void Dispose() - { - } - - public List DryRunRecords { get; } = []; - - public record DryRunRecord(string Method, string Description); - - public Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? position = null) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreateWritingSystem), - $"Create writing system {writingSystem.Type} between {position?.Previous} and {position?.Next}")); - return Task.FromResult(writingSystem); - } - - public async Task UpdateWritingSystem(WritingSystemId id, - WritingSystemType type, - UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateWritingSystem), - $"Update writing system {type}, changes: {update.Summarize()}")); - var ws = await _api.GetWritingSystems(); - return (type switch - { - WritingSystemType.Vernacular => ws.Vernacular, - WritingSystemType.Analysis => ws.Analysis, - _ => throw new InvalidOperationException("unknown type " + type) - }).First(w => w.WsId == id); - } - - public Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateWritingSystem), $"Update {after.Type} writing system {after.WsId}")); - return Task.FromResult(after); - } - - public async Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) - { - DryRunRecords.Add(new DryRunRecord(nameof(MoveWritingSystem), $"Move writing system {id} between {between.Previous} and {between.Next}")); - await Task.CompletedTask; - } - - public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); - return Task.FromResult(partOfSpeech); // Since this is a dry run, api.GetPartOfSpeech would return null - } - - public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}")); - return _api.GetPartOfSpeech(id)!; - } - - public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}")); - return Task.FromResult(after); - } - - public Task DeletePartOfSpeech(Guid id) - { - DryRunRecords.Add(new DryRunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}")); - return Task.CompletedTask; - } - - public Task CreateSemanticDomain(SemanticDomain semanticDomain) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreateSemanticDomain), - $"Create semantic domain {semanticDomain.Name}")); - return Task.FromResult(semanticDomain); - } - - public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {id}")); - return _api.GetSemanticDomain(id)!; - } - - public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}")); - return Task.FromResult(after); - } - - public Task DeleteSemanticDomain(Guid id) - { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}")); - return Task.CompletedTask; - } - - public Task CreateComplexFormType(ComplexFormType complexFormType) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreateComplexFormType), - $"Create complex form type {complexFormType.Name}")); - return Task.FromResult(complexFormType); - } - - public async Task UpdateComplexFormType(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateComplexFormType), $"Update complex form type {id}")); - return await _api.GetComplexFormType(id) ?? throw new NullReferenceException($"unable to find complex form type with id {id}"); - } - - public Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateComplexFormType), $"Update complex form type {after.Id}")); - return Task.FromResult(after); - } - - public Task DeleteComplexFormType(Guid id) - { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormType), $"Delete complex form type {id}")); - return Task.CompletedTask; - } - - public Task CreateMorphType(MorphType morphType) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})")); - return Task.FromResult(morphType); - } - - public async Task UpdateMorphType(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateMorphType), $"Update morph type {id}")); - return await _api.GetMorphType(id) ?? throw new NullReferenceException($"unable to find morph type with id {id}"); - } - - public Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateMorphType), $"Update morph type {after.Id}")); - return Task.FromResult(after); - } - - public Task CreateEntry(Entry entry, CreateEntryOptions? options) - { - options ??= new CreateEntryOptions(); - DryRunRecords.Add(new DryRunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options})")); - // Only return what would have been persisted - if (options.IncludeComplexFormsAndComponents) - return Task.FromResult(entry); - else - return Task.FromResult(entry with { Components = [], ComplexForms = [] }); - } - - public Task UpdateEntry(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateEntry), $"Update entry {id}")); - return _api.GetEntry(id)!; - } - - public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateEntry), $"Update entry {after.Id}")); - return Task.FromResult(after); - } - - public Task DeleteEntry(Guid id) - { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteEntry), $"Delete entry {id}")); - return Task.CompletedTask; - } - - public async Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) - { - DryRunRecords.Add(new DryRunRecord(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId}, from entry {entryId}")); - await Task.CompletedTask; - } - public Task CreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreateSense), $"Create sense {sense.Gloss} between {position?.Previous} and {position?.Next}")); - return Task.FromResult(sense); - } - - public async Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSense), - $"Update sense {senseId}, changes: {update.Summarize()}")); - var entry = await _api.GetEntry(entryId) ?? - throw new NullReferenceException($"unable to find entry with id {entryId}"); - var sense = entry.Senses.First(s => s.Id == senseId); - return sense; - } - - public async Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSense), - $"Update sense {after.Id}")); - return await _api.GetSense(entryId, after.Id) ?? throw new NullReferenceException($"unable to find sense with id {after.Id}"); - } - - public Task MoveSense(Guid entryId, Guid senseId, BetweenPosition between) - { - DryRunRecords.Add(new DryRunRecord(nameof(MoveSense), $"Move sense {senseId} between {between.Previous} and {between.Next}")); - return Task.CompletedTask; - } - - public Task DeleteSense(Guid entryId, Guid senseId) - { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteSense), $"Delete sense {senseId}")); - return Task.CompletedTask; - } - - public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) - { - DryRunRecords.Add(new DryRunRecord(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Name}")); - return Task.CompletedTask; - } - - public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) - { - DryRunRecords.Add(new DryRunRecord(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId}")); - return Task.CompletedTask; - } - - public Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) - { - DryRunRecords.Add(new DryRunRecord(nameof(SetSensePartOfSpeech), $"Set part of speech {partOfSpeechId}")); - return Task.CompletedTask; - } - - public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence} between {position?.Previous} and {position?.Next}")); - return Task.FromResult(exampleSentence); - } - - public async Task UpdateExampleSentence(Guid entryId, - Guid senseId, - Guid exampleSentenceId, - UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence), - $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); - var exampleSentence = await _api.GetExampleSentence(entryId, senseId, exampleSentenceId); - return exampleSentence ?? throw new NullReferenceException($"unable to find example sentence with id {exampleSentenceId}"); - } - - public Task UpdateExampleSentence(Guid entryId, - Guid senseId, - ExampleSentence before, - ExampleSentence after, - IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence), $"Update example sentence {after.Id}")); - return Task.FromResult(after); - } - - public Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) - { - DryRunRecords.Add(new DryRunRecord(nameof(MoveExampleSentence), $"Move example sentence {exampleId} between {between.Previous} and {between.Next}")); - return Task.CompletedTask; - } - - public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) - { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}")); - return Task.CompletedTask; - } - - public Task AddTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Translation translation) - { - DryRunRecords.Add(new DryRunRecord(nameof(AddTranslation), $"Add translation {translation.Id} to example sentence {exampleSentenceId}")); - return Task.CompletedTask; - } - - public Task RemoveTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId) - { - DryRunRecords.Add(new DryRunRecord(nameof(RemoveTranslation), $"Remove translation {translationId} from example sentence {exampleSentenceId}")); - return Task.CompletedTask; - } - - public Task UpdateTranslation(Guid entryId, - Guid senseId, - Guid exampleSentenceId, - Guid translationId, - UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateTranslation), $"Update translation {translationId} in example sentence {exampleSentenceId}")); - return Task.CompletedTask; - } - - - public Task CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? position = null) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreatePicture), $"Create picture {picture.Caption} between {position?.Previous} and {position?.Next}")); - return Task.FromResult(picture); - } - - public async Task UpdatePicture(Guid entryId, - Guid senseId, - Guid pictureId, - UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePicture), - $"Update picture {pictureId}, changes: {update.Summarize()}")); - var picture = await _api.GetPicture(entryId, senseId, pictureId); - return picture ?? throw new NullReferenceException($"unable to find picture with id {pictureId}"); - } - - public Task UpdatePicture(Guid entryId, - Guid senseId, - Picture before, - Picture after, - IMiniLcmApi? api) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePicture), $"Update picture {after.Id}")); - return Task.FromResult(after); - } - - public Task MovePicture(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) - { - DryRunRecords.Add(new DryRunRecord(nameof(MovePicture), $"Move picture {exampleId} between {between.Previous} and {between.Next}")); - return Task.CompletedTask; - } - - public Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) - { - DryRunRecords.Add(new DryRunRecord(nameof(DeletePicture), $"Delete picture {pictureId}")); - return Task.CompletedTask; - } - - public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? between = null) - { - var complexFormName = ComplexFormName(complexFormComponent); - var componentName = ComplexFormComponentName(complexFormComponent); - var previous = ComplexFormComponentName(between?.Previous); - var next = ComplexFormComponentName(between?.Next); - DryRunRecords.Add(new DryRunRecord(nameof(CreateComplexFormComponent), $"Create complex form component complex entry: {complexFormName}, component entry: {componentName}, between {previous} and {next}")); - return Task.FromResult(complexFormComponent); - } - - public Task MoveComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition between) - { - var componentName = ComplexFormComponentName(complexFormComponent); - var previous = ComplexFormComponentName(between.Previous); - var next = ComplexFormComponentName(between.Next); - DryRunRecords.Add(new DryRunRecord(nameof(MoveComplexFormComponent), $"Move complex form component {componentName} between {previous} and {next}")); - return Task.CompletedTask; - } - - public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) - { - var componentName = ComplexFormComponentName(complexFormComponent); - DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component: {componentName}")); - return Task.CompletedTask; - } - - public async Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) - { - DryRunRecords.Add(new DryRunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); - await Task.CompletedTask; - } - - public async Task CreatePublication(Publication pub) - { - DryRunRecords.Add(new DryRunRecord(nameof(CreatePublication), $"Create publication {pub.Id}")); - return await Task.FromResult(pub); - } - - public async Task UpdatePublication(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePublication), $"Update publication {id}")); - return await _api.GetPublication(id) ?? throw NotFoundException.ForType(id); - } - - public async Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) - { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePublication), $"Update publication {before.Id}")); - return await _api.GetPublication(before.Id) ?? throw NotFoundException.ForType(before.Id); - } - - public Task DeletePublication(Guid id) - { - DryRunRecords.Add(new DryRunRecord(nameof(DeletePublication), $"Delete publication {id}")); - return Task.CompletedTask; - } - - public Task AddPublication(Guid entryId, Guid publicationId) - { - DryRunRecords.Add(new DryRunRecord(nameof(AddPublication), $"Add publication {publicationId} to entry {entryId}")); - return Task.CompletedTask; - } - - public Task RemovePublication(Guid entryId, Guid publicationId) - { - DryRunRecords.Add(new DryRunRecord(nameof(RemovePublication), $"Remove publication {publicationId} from entry {entryId}")); - return Task.CompletedTask; - } - - #region Submit (sync's result-less write variants) - // Record-only. Overridden explicitly (not inherited from the interface default, which would route to the - // returning Update* and re-read the object) so a dry-run of a conflicted project doesn't throw on the - // now-deleted object. - public Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}")); - return Task.CompletedTask; - } - - public Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}")); - return Task.CompletedTask; - } - - public Task SubmitUpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateExampleSentence), $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); - return Task.CompletedTask; - } - - public Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateSense), $"Create sense {sense.Gloss}")); - return Task.CompletedTask; - } - - public Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); - return Task.CompletedTask; - } - - public Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateComplexFormComponent), $"Create complex form component {ComplexFormComponentName(complexFormComponent)}")); - return Task.CompletedTask; - } - - public Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePartOfSpeech), $"Update part of speech {id}")); - return Task.CompletedTask; - } - - public Task SubmitUpdatePublication(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePublication), $"Update publication {id}")); - return Task.CompletedTask; - } - - public Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSemanticDomain), $"Update semantic domain {id}")); - return Task.CompletedTask; - } - - public Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput update) - { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateComplexFormType), $"Update complex form type {id}")); - return Task.CompletedTask; - } - #endregion - - private string ComplexFormComponentName(ComplexFormComponent? component) - { - if (component == null) return "null"; - return $"{component.ComponentHeadword} ({component.ComponentEntryId}:{component.ComponentSenseId})"; - } - - private string ComplexFormName(ComplexFormComponent? component) - { - if (component == null) return "null"; - return $"{component.ComplexFormHeadword} ({component.ComplexFormEntryId})"; - } - - //this is now called to this method from the ResumableImportApi, but calling GetEntries will fail because there's no writing systems - async IAsyncEnumerable IMiniLcmReadApi.GetEntries(QueryOptions? options) - { - if (await _api.CountEntries() > 0) - { - await foreach (var entry in _api.GetEntries(options)) - { - yield return entry; - } - } - } -} diff --git a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs new file mode 100644 index 0000000000..522b57fe87 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs @@ -0,0 +1,467 @@ +using MiniLcm; +using MiniLcm.Models; +using MiniLcm.SyncHelpers; + +namespace FwLiteProjectSync; + +public partial class RecordingMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi +{ + + public record RunRecord(string Method, string Description); + + private readonly IMiniLcmApi _api = api; + + // Writes aren't auto-forwarded, so the compiler forces each to be recorded + [BeaKona.AutoInterface] + private IMiniLcmReadApi ReadApi => _api; + + public List RunRecords { get; } = []; + + // Wrapped api is the caller's to dispose + public void Dispose() + { + } + + public async Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? position = null) + { + RunRecords.Add(new RunRecord(nameof(CreateWritingSystem), + $"Create writing system {writingSystem.Type} between {position?.Previous} and {position?.Next}")); + return await _api.CreateWritingSystem(writingSystem, position); + } + + public async Task UpdateWritingSystem(WritingSystemId id, + WritingSystemType type, + UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdateWritingSystem), + $"Update writing system {type}, changes: {update.Summarize()}")); + return await _api.UpdateWritingSystem(id, type, update); + } + + public async Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdateWritingSystem), $"Update {after.Type} writing system {after.WsId}")); + return await _api.UpdateWritingSystem(before, after, api); + } + + public async Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) + { + RunRecords.Add(new RunRecord(nameof(MoveWritingSystem), $"Move writing system {id} between {between.Previous} and {between.Next}")); + await _api.MoveWritingSystem(id, type, between); + } + + public async Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) + { + RunRecords.Add(new RunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); + return await _api.CreatePartOfSpeech(partOfSpeech); + } + + public async Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}")); + return await _api.UpdatePartOfSpeech(id, update); + } + + public async Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}")); + return await _api.UpdatePartOfSpeech(before, after, api); + } + + public async Task DeletePartOfSpeech(Guid id) + { + RunRecords.Add(new RunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}")); + await _api.DeletePartOfSpeech(id); + } + + public async Task CreateSemanticDomain(SemanticDomain semanticDomain) + { + RunRecords.Add(new RunRecord(nameof(CreateSemanticDomain), + $"Create semantic domain {semanticDomain.Name}")); + return await _api.CreateSemanticDomain(semanticDomain); + } + + public async Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {id}")); + return await _api.UpdateSemanticDomain(id, update); + } + + public async Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}")); + return await _api.UpdateSemanticDomain(before, after, api); + } + + public async Task DeleteSemanticDomain(Guid id) + { + RunRecords.Add(new RunRecord(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}")); + await _api.DeleteSemanticDomain(id); + } + + public async Task CreateComplexFormType(ComplexFormType complexFormType) + { + RunRecords.Add(new RunRecord(nameof(CreateComplexFormType), + $"Create complex form type {complexFormType.Name}")); + return await _api.CreateComplexFormType(complexFormType); + } + + public async Task UpdateComplexFormType(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdateComplexFormType), $"Update complex form type {id}")); + return await _api.UpdateComplexFormType(id, update); + } + + public async Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdateComplexFormType), $"Update complex form type {after.Id}")); + return await _api.UpdateComplexFormType(before, after, api); + } + + public async Task DeleteComplexFormType(Guid id) + { + RunRecords.Add(new RunRecord(nameof(DeleteComplexFormType), $"Delete complex form type {id}")); + await _api.DeleteComplexFormType(id); + } + + public async Task CreateMorphType(MorphType morphType) + { + RunRecords.Add(new RunRecord(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})")); + return await _api.CreateMorphType(morphType); + } + + public async Task UpdateMorphType(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdateMorphType), $"Update morph type {id}")); + return await _api.UpdateMorphType(id, update); + } + + public async Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdateMorphType), $"Update morph type {after.Id}")); + return await _api.UpdateMorphType(before, after, api); + } + + public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) + { + RunRecords.Add(new RunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options ?? new CreateEntryOptions()})")); + return await _api.CreateEntry(entry, options); + } + + public async Task UpdateEntry(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdateEntry), $"Update entry {id}")); + return await _api.UpdateEntry(id, update); + } + + public async Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdateEntry), $"Update entry {after.Id}")); + return await _api.UpdateEntry(before, after, api); + } + + public async Task DeleteEntry(Guid id) + { + RunRecords.Add(new RunRecord(nameof(DeleteEntry), $"Delete entry {id}")); + await _api.DeleteEntry(id); + } + + public async Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) + { + RunRecords.Add(new RunRecord(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId}, from entry {entryId}")); + await _api.RemoveComplexFormType(entryId, complexFormTypeId); + } + + public async Task CreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) + { + RunRecords.Add(new RunRecord(nameof(CreateSense), $"Create sense {sense.Gloss} between {position?.Previous} and {position?.Next}")); + return await _api.CreateSense(entryId, sense, position); + } + + public async Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdateSense), + $"Update sense {senseId}, changes: {update.Summarize()}")); + return await _api.UpdateSense(entryId, senseId, update); + } + + public async Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdateSense), + $"Update sense {after.Id}")); + return await _api.UpdateSense(entryId, before, after, api); + } + + public async Task MoveSense(Guid entryId, Guid senseId, BetweenPosition between) + { + RunRecords.Add(new RunRecord(nameof(MoveSense), $"Move sense {senseId} between {between.Previous} and {between.Next}")); + await _api.MoveSense(entryId, senseId, between); + } + + public async Task DeleteSense(Guid entryId, Guid senseId) + { + RunRecords.Add(new RunRecord(nameof(DeleteSense), $"Delete sense {senseId}")); + await _api.DeleteSense(entryId, senseId); + } + + public async Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + { + RunRecords.Add(new RunRecord(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Name}")); + await _api.AddSemanticDomainToSense(senseId, semanticDomain); + } + + public async Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + RunRecords.Add(new RunRecord(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId}")); + await _api.RemoveSemanticDomainFromSense(senseId, semanticDomainId); + } + + public async Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) + { + RunRecords.Add(new RunRecord(nameof(SetSensePartOfSpeech), $"Set part of speech {partOfSpeechId}")); + await _api.SetSensePartOfSpeech(senseId, partOfSpeechId); + } + + public async Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) + { + RunRecords.Add(new RunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence} between {position?.Previous} and {position?.Next}")); + return await _api.CreateExampleSentence(entryId, senseId, exampleSentence, position); + } + + public async Task UpdateExampleSentence(Guid entryId, + Guid senseId, + Guid exampleSentenceId, + UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdateExampleSentence), + $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); + return await _api.UpdateExampleSentence(entryId, senseId, exampleSentenceId, update); + } + + public async Task UpdateExampleSentence(Guid entryId, + Guid senseId, + ExampleSentence before, + ExampleSentence after, + IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdateExampleSentence), $"Update example sentence {after.Id}")); + return await _api.UpdateExampleSentence(entryId, senseId, before, after, api); + } + + public async Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) + { + RunRecords.Add(new RunRecord(nameof(MoveExampleSentence), $"Move example sentence {exampleId} between {between.Previous} and {between.Next}")); + await _api.MoveExampleSentence(entryId, senseId, exampleId, between); + } + + public async Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) + { + RunRecords.Add(new RunRecord(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}")); + await _api.DeleteExampleSentence(entryId, senseId, exampleSentenceId); + } + + public async Task AddTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Translation translation) + { + RunRecords.Add(new RunRecord(nameof(AddTranslation), $"Add translation {translation.Id} to example sentence {exampleSentenceId}")); + await _api.AddTranslation(entryId, senseId, exampleSentenceId, translation); + } + + public async Task RemoveTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId) + { + RunRecords.Add(new RunRecord(nameof(RemoveTranslation), $"Remove translation {translationId} from example sentence {exampleSentenceId}")); + await _api.RemoveTranslation(entryId, senseId, exampleSentenceId, translationId); + } + + public async Task UpdateTranslation(Guid entryId, + Guid senseId, + Guid exampleSentenceId, + Guid translationId, + UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdateTranslation), $"Update translation {translationId} in example sentence {exampleSentenceId}")); + await _api.UpdateTranslation(entryId, senseId, exampleSentenceId, translationId, update); + } + + + public async Task CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? position = null) + { + RunRecords.Add(new RunRecord(nameof(CreatePicture), $"Create picture {picture.Caption} between {position?.Previous} and {position?.Next}")); + return await _api.CreatePicture(entryId, senseId, picture, position); + } + + public async Task UpdatePicture(Guid entryId, + Guid senseId, + Guid pictureId, + UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdatePicture), + $"Update picture {pictureId}, changes: {update.Summarize()}")); + return await _api.UpdatePicture(entryId, senseId, pictureId, update); + } + + public async Task UpdatePicture(Guid entryId, + Guid senseId, + Picture before, + Picture after, + IMiniLcmApi? api) + { + RunRecords.Add(new RunRecord(nameof(UpdatePicture), $"Update picture {after.Id}")); + return await _api.UpdatePicture(entryId, senseId, before, after, api); + } + + public async Task MovePicture(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) + { + RunRecords.Add(new RunRecord(nameof(MovePicture), $"Move picture {exampleId} between {between.Previous} and {between.Next}")); + await _api.MovePicture(entryId, senseId, exampleId, between); + } + + public async Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) + { + RunRecords.Add(new RunRecord(nameof(DeletePicture), $"Delete picture {pictureId}")); + await _api.DeletePicture(entryId, senseId, pictureId); + } + + public async Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? between = null) + { + var complexFormName = ComplexFormName(complexFormComponent); + var componentName = ComplexFormComponentName(complexFormComponent); + var previous = ComplexFormComponentName(between?.Previous); + var next = ComplexFormComponentName(between?.Next); + RunRecords.Add(new RunRecord(nameof(CreateComplexFormComponent), $"Create complex form component complex entry: {complexFormName}, component entry: {componentName}, between {previous} and {next}")); + return await _api.CreateComplexFormComponent(complexFormComponent, between); + } + + public async Task MoveComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition between) + { + var componentName = ComplexFormComponentName(complexFormComponent); + var previous = ComplexFormComponentName(between.Previous); + var next = ComplexFormComponentName(between.Next); + RunRecords.Add(new RunRecord(nameof(MoveComplexFormComponent), $"Move complex form component {componentName} between {previous} and {next}")); + await _api.MoveComplexFormComponent(complexFormComponent, between); + } + + public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) + { + var componentName = ComplexFormComponentName(complexFormComponent); + RunRecords.Add(new RunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component: {componentName}")); + await _api.DeleteComplexFormComponent(complexFormComponent); + } + + public async Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) + { + RunRecords.Add(new RunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); + await _api.AddComplexFormType(entryId, complexFormTypeId); + } + + public async Task CreatePublication(Publication pub) + { + RunRecords.Add(new RunRecord(nameof(CreatePublication), $"Create publication {pub.Id}")); + return await _api.CreatePublication(pub); + } + + public async Task UpdatePublication(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(UpdatePublication), $"Update publication {id}")); + return await _api.UpdatePublication(id, update); + } + + public async Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) + { + RunRecords.Add(new RunRecord(nameof(UpdatePublication), $"Update publication {before.Id}")); + return await _api.UpdatePublication(before, after, api); + } + + public async Task DeletePublication(Guid id) + { + RunRecords.Add(new RunRecord(nameof(DeletePublication), $"Delete publication {id}")); + await _api.DeletePublication(id); + } + + public async Task AddPublication(Guid entryId, Guid publicationId) + { + RunRecords.Add(new RunRecord(nameof(AddPublication), $"Add publication {publicationId} to entry {entryId}")); + await _api.AddPublication(entryId, publicationId); + } + + public async Task RemovePublication(Guid entryId, Guid publicationId) + { + RunRecords.Add(new RunRecord(nameof(RemovePublication), $"Remove publication {publicationId} from entry {entryId}")); + await _api.RemovePublication(entryId, publicationId); + } + + #region Submit (sync's result-less write variants) + // Submit* are writes, so they're implemented here to record and forward. Any not listed falls back to the + // interface default, which routes to the recording Update*/Move* above. + public async Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}")); + await _api.SubmitUpdateEntry(id, update); + } + + public async Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(SubmitUpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}")); + await _api.SubmitUpdateSense(entryId, senseId, update); + } + + public async Task SubmitUpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(SubmitUpdateExampleSentence), $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); + await _api.SubmitUpdateExampleSentence(entryId, senseId, exampleSentenceId, update); + } + + public async Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) + { + RunRecords.Add(new RunRecord(nameof(SubmitCreateSense), $"Create sense {sense.Gloss}")); + await _api.SubmitCreateSense(entryId, sense, position); + } + + public async Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) + { + RunRecords.Add(new RunRecord(nameof(SubmitCreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); + await _api.SubmitCreateExampleSentence(entryId, senseId, exampleSentence, position); + } + + public async Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) + { + RunRecords.Add(new RunRecord(nameof(SubmitCreateComplexFormComponent), $"Create complex form component {ComplexFormComponentName(complexFormComponent)}")); + await _api.SubmitCreateComplexFormComponent(complexFormComponent, position); + } + + public async Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(SubmitUpdatePartOfSpeech), $"Update part of speech {id}")); + await _api.SubmitUpdatePartOfSpeech(id, update); + } + + public async Task SubmitUpdatePublication(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(SubmitUpdatePublication), $"Update publication {id}")); + await _api.SubmitUpdatePublication(id, update); + } + + public async Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(SubmitUpdateSemanticDomain), $"Update semantic domain {id}")); + await _api.SubmitUpdateSemanticDomain(id, update); + } + + public async Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput update) + { + RunRecords.Add(new RunRecord(nameof(SubmitUpdateComplexFormType), $"Update complex form type {id}")); + await _api.SubmitUpdateComplexFormType(id, update); + } + #endregion + + private string ComplexFormComponentName(ComplexFormComponent? component) + { + if (component == null) return "null"; + return $"{component.ComponentHeadword} ({component.ComponentEntryId}:{component.ComponentSenseId})"; + } + + private string ComplexFormName(ComplexFormComponent? component) + { + if (component == null) return "null"; + return $"{component.ComplexFormHeadword} ({component.ComplexFormEntryId})"; + } +} diff --git a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs new file mode 100644 index 0000000000..e7d903a445 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs @@ -0,0 +1,382 @@ +using MiniLcm; +using MiniLcm.Exceptions; +using MiniLcm.Models; +using MiniLcm.SyncHelpers; + +namespace FwLiteProjectSync; + +public partial class WriteIgnoringMiniLcmApi(IMiniLcmReadApi api) : IMiniLcmApi +{ + [BeaKona.AutoInterface] + private IMiniLcmReadApi ReadApi => api; + + public void Dispose() + { + } + + public Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? position = null) + { + return Task.FromResult(writingSystem); + } + + public async Task UpdateWritingSystem(WritingSystemId id, + WritingSystemType type, + UpdateObjectInput update) + { + var ws = await api.GetWritingSystems(); + return (type switch + { + WritingSystemType.Vernacular => ws.Vernacular, + WritingSystemType.Analysis => ws.Analysis, + _ => throw new InvalidOperationException("unknown type " + type) + }).First(w => w.WsId == id); + } + + public Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api) + { + return Task.FromResult(after); + } + + public Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) + { + return Task.CompletedTask; + } + + public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) + { + // Ignored write isn't readable back, so return the input. + return Task.FromResult(partOfSpeech); + } + + public async Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + return (await api.GetPartOfSpeech(id))!; + } + + public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) + { + return Task.FromResult(after); + } + + public Task DeletePartOfSpeech(Guid id) + { + return Task.CompletedTask; + } + + public Task CreateSemanticDomain(SemanticDomain semanticDomain) + { + return Task.FromResult(semanticDomain); + } + + public async Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + return (await api.GetSemanticDomain(id))!; + } + + public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) + { + return Task.FromResult(after); + } + + public Task DeleteSemanticDomain(Guid id) + { + return Task.CompletedTask; + } + + public Task CreateComplexFormType(ComplexFormType complexFormType) + { + return Task.FromResult(complexFormType); + } + + public async Task UpdateComplexFormType(Guid id, UpdateObjectInput update) + { + return await api.GetComplexFormType(id) ?? throw new NullReferenceException($"unable to find complex form type with id {id}"); + } + + public Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api) + { + return Task.FromResult(after); + } + + public Task DeleteComplexFormType(Guid id) + { + return Task.CompletedTask; + } + + public Task CreateMorphType(MorphType morphType) + { + return Task.FromResult(morphType); + } + + public async Task UpdateMorphType(Guid id, UpdateObjectInput update) + { + return await api.GetMorphType(id) ?? throw new NullReferenceException($"unable to find morph type with id {id}"); + } + + public Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api) + { + return Task.FromResult(after); + } + + public Task CreateEntry(Entry entry, CreateEntryOptions? options) + { + options ??= new CreateEntryOptions(); + // Return only what a real create would persist; without this option those get dropped. + if (options.IncludeComplexFormsAndComponents) + return Task.FromResult(entry); + return Task.FromResult(entry with { Components = [], ComplexForms = [] }); + } + + public async Task UpdateEntry(Guid id, UpdateObjectInput update) + { + return (await api.GetEntry(id))!; + } + + public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) + { + return Task.FromResult(after); + } + + public Task DeleteEntry(Guid id) + { + return Task.CompletedTask; + } + + public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) + { + return Task.CompletedTask; + } + + public Task CreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) + { + return Task.FromResult(sense); + } + + public async Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + { + var entry = await api.GetEntry(entryId) ?? + throw new NullReferenceException($"unable to find entry with id {entryId}"); + var sense = entry.Senses.First(s => s.Id == senseId); + return sense; + } + + public async Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api) + { + return await ReadApi.GetSense(entryId, after.Id) ?? throw new NullReferenceException($"unable to find sense with id {after.Id}"); + } + + public Task MoveSense(Guid entryId, Guid senseId, BetweenPosition between) + { + return Task.CompletedTask; + } + + public Task DeleteSense(Guid entryId, Guid senseId) + { + return Task.CompletedTask; + } + + public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + { + return Task.CompletedTask; + } + + public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + return Task.CompletedTask; + } + + public Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) + { + return Task.CompletedTask; + } + + public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) + { + return Task.FromResult(exampleSentence); + } + + public async Task UpdateExampleSentence(Guid entryId, + Guid senseId, + Guid exampleSentenceId, + UpdateObjectInput update) + { + var exampleSentence = await api.GetExampleSentence(entryId, senseId, exampleSentenceId); + return exampleSentence ?? throw new NullReferenceException($"unable to find example sentence with id {exampleSentenceId}"); + } + + public Task UpdateExampleSentence(Guid entryId, + Guid senseId, + ExampleSentence before, + ExampleSentence after, + IMiniLcmApi? api) + { + return Task.FromResult(after); + } + + public Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) + { + return Task.CompletedTask; + } + + public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) + { + return Task.CompletedTask; + } + + public Task AddTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Translation translation) + { + return Task.CompletedTask; + } + + public Task RemoveTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId) + { + return Task.CompletedTask; + } + + public Task UpdateTranslation(Guid entryId, + Guid senseId, + Guid exampleSentenceId, + Guid translationId, + UpdateObjectInput update) + { + return Task.CompletedTask; + } + + + public Task CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? position = null) + { + return Task.FromResult(picture); + } + + public async Task UpdatePicture(Guid entryId, + Guid senseId, + Guid pictureId, + UpdateObjectInput update) + { + var picture = await api.GetPicture(entryId, senseId, pictureId); + return picture ?? throw new NullReferenceException($"unable to find picture with id {pictureId}"); + } + + public Task UpdatePicture(Guid entryId, + Guid senseId, + Picture before, + Picture after, + IMiniLcmApi? api) + { + return Task.FromResult(after); + } + + public Task MovePicture(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) + { + return Task.CompletedTask; + } + + public Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) + { + return Task.CompletedTask; + } + + public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? between = null) + { + return Task.FromResult(complexFormComponent); + } + + public Task MoveComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition between) + { + return Task.CompletedTask; + } + + public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) + { + return Task.CompletedTask; + } + + public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) + { + return Task.CompletedTask; + } + + public Task CreatePublication(Publication pub) + { + return Task.FromResult(pub); + } + + public async Task UpdatePublication(Guid id, UpdateObjectInput update) + { + return await api.GetPublication(id) ?? throw NotFoundException.ForType(id); + } + + public async Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) + { + return await ReadApi.GetPublication(before.Id) ?? throw NotFoundException.ForType(before.Id); + } + + public Task DeletePublication(Guid id) + { + return Task.CompletedTask; + } + + public Task AddPublication(Guid entryId, Guid publicationId) + { + return Task.CompletedTask; + } + + public Task RemovePublication(Guid entryId, Guid publicationId) + { + return Task.CompletedTask; + } + + #region Submit (sync's result-less write variants) + // Explicit, not the interface default: that routes to the returning Update* and re-reads the object, which + // throws when a dry run hits an object the other side already deleted. + public Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) + { + return Task.CompletedTask; + } + + public Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + { + return Task.CompletedTask; + } + + public Task SubmitUpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput update) + { + return Task.CompletedTask; + } + + public Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) + { + return Task.CompletedTask; + } + + public Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) + { + return Task.CompletedTask; + } + + public Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) + { + return Task.CompletedTask; + } + + public Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + return Task.CompletedTask; + } + + public Task SubmitUpdatePublication(Guid id, UpdateObjectInput update) + { + return Task.CompletedTask; + } + + public Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + return Task.CompletedTask; + } + + public Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput update) + { + return Task.CompletedTask; + } + #endregion +} diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 69f66956ea..b982cc7d37 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -38,6 +38,7 @@ public class CrdtMiniLcmApi( { private Guid ClientId { get; } = projectService.ProjectData.ClientId; public ProjectData ProjectData => projectService.ProjectData; + public CrdtProject Project => projectService.Project; private LcmCrdtConfig LcmConfig => config.Value; private CommitMetadata NewMetadata() diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index a7aa058dc7..d145988801 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -284,6 +284,40 @@ public virtual async Task CreateProject(CreateProjectRequest reques return crdtProject; } + /// + /// Opens a throwaway copy of a project's database in its own scope and a temp dir + /// + public async Task OpenTempProjectCopy(CrdtProject source) + { + var tempDir = Path.Combine(Path.GetTempPath(), "FwLiteProjectCopies"); + Directory.CreateDirectory(tempDir); + var tempPath = Path.Combine(tempDir, $"{source.Name}-{Guid.NewGuid():N}.sqlite"); + + // Nothing owns the temp file or scope until TempCrdtProjectCopy is returned, so undo both if we throw first. + AsyncServiceScope? scope = null; + try + { + await using (var sourceConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = source.DbPath }.ConnectionString)) + await using (var copyConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = tempPath }.ConnectionString)) + { + await sourceConnection.OpenAsync(); + await copyConnection.OpenAsync(); + sourceConnection.BackupDatabase(copyConnection); + } + + scope = provider.CreateAsyncScope(); + // OpenCrdtProject is typed to the interface, but a CRDT project always resolves a CrdtMiniLcmApi. + var api = (CrdtMiniLcmApi)await scope.Value.ServiceProvider.OpenCrdtProject(new CrdtProject(source.Name, tempPath)); + return new TempCrdtProjectCopy(api, scope.Value, () => EnsureDeleteProject(tempPath, suppressException: true)); + } + catch + { + if (scope is not null) await scope.Value.DisposeAsync(); + await EnsureDeleteProject(tempPath, suppressException: true); + throw; + } + } + private Task EnsureDeleteProject(string sqliteFile, bool suppressException = false) { return Task.Run(async () => diff --git a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs new file mode 100644 index 0000000000..9436c95710 --- /dev/null +++ b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace LcmCrdt; + +/// +/// A throwaway, disposable copy of a CRDT project's db +/// +public sealed class TempCrdtProjectCopy(CrdtMiniLcmApi api, AsyncServiceScope scope, Func cleanup) : IAsyncDisposable +{ + public CrdtMiniLcmApi Api { get; } = api; + + public async ValueTask DisposeAsync() + { + // finally so the temp files are deleted even if scope disposal throws. + try + { + await scope.DisposeAsync(); + } + finally + { + await cleanup(); + } + } +} diff --git a/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs b/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs index 2737455254..0f4567fdfc 100644 --- a/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs +++ b/backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs @@ -252,6 +252,7 @@ private ServiceProvider BuildServiceProvider( MockBehavior.Strict, null!, NullLogger.Instance, + null!, null!); syncService