From dd684d191a344c8b0a3e059560e2ec152e3a7465 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 16:56:15 +0200 Subject: [PATCH 01/12] Make dry-run sync faithful by running the CRDT side against a throwaway copy The sync writes the CRDT then reads it back within a pass (direction B reads the CRDT after direction A wrote it). The old dry-run wrapped the CRDT in a record-only DryRunMiniLcmApi, so that read-back saw stale state: direction B thought fwdata's whole inventory should be deleted. Mostly that garbage was silently recorded, but MorphTypeSync.Remove threw on it, and the fwdata-side change counts were meaningless (the Sena3 test only compared CRDT changes and documented the fwdata side as garbage). Run the CRDT side of a dry run against a disposable copy of its own database instead, so writes really apply and read back faithfully, and record what was applied. FwData is read once up front (never read back) and its file must not change, so it stays a record-only DryRunMiniLcmApi. - CrdtProjectsService.OpenProjectCopy backs up the sqlite db to a temp file and opens it as a throwaway project in its own scope; TempCrdtProjectCopy disposes the scope and deletes the temp files. - RecordingMiniLcmApi records each write and forwards it to the copy (AutoInterface forwards reads/Submit*/everything else, so it's correct even where it doesn't record). - IDryRunRecorder lets the sync pull records from either wrapper. - CrdtRepairs.SyncMissingTranslationIds drops its dryRun flag: with the copy, the CRDT write is always safe. - Sena3 DryRunSync tests now also assert FwdataChanges; DryRunSync_MakesNoChanges gets a consistent writing-system setup (the faithful run surfaces the inconsistency the fake one hid). This also fixes PR #2483's failure: with morph-type seeding removed the CRDT starts blank, and the dry run no longer throws on the phantom morph-type removal (verified by running the DryRunSync tests with seeding disabled). Co-Authored-By: Claude Opus 4.8 --- .../FwLiteProjectSync.Tests/Sena3SyncTests.cs | 16 +- .../CrdtFwdataProjectSyncService.cs | 32 ++- .../FwLite/FwLiteProjectSync/CrdtRepairs.cs | 4 +- .../FwLiteProjectSync/DryRunMiniLcmApi.cs | 2 +- .../FwLiteProjectSync/IDryRunRecorder.cs | 11 + .../FwLiteProjectSync/RecordingMiniLcmApi.cs | 223 ++++++++++++++++++ backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs | 1 + backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 26 ++ backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs | 20 ++ 9 files changed, 317 insertions(+), 18 deletions(-) create mode 100644 backend/FwLite/FwLiteProjectSync/IDryRunRecorder.cs create mode 100644 backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs create mode 100644 backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 6654196a7c..e8e4729a1e 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -120,11 +120,15 @@ public async Task DryRunImport_MakesTheSameChangesAsImport() [Trait("Category", "Integration")] public async Task DryRunSync_MakesNoChanges() { - var projectSnapshot = await CreateAndSaveMinimalSnapshot(); - await WorkaroundMissingWritingSystems(); + // The snapshot's writing systems must match the CRDT's, otherwise the (now faithful) dry run tries to + // re-create writing systems the CRDT already has — a real sync would throw there too. Import them so + // both sides agree, the same way DryRunSync_MakesTheSameChangesAsSync does. + await _project.Services.GetRequiredService() + .ImportWritingSystems(_crdtApi, await _fwDataApi.GetWritingSystems()); + var projectSnapshot = await CreateAndSaveMinimalSnapshot(withWritingSystems: true); _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); await _syncService.SyncDryRun(_crdtApi, _fwDataApi, projectSnapshot); - //should still be empty + // The dry run applies the CRDT side to a throwaway copy, so the real project is untouched. _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); } @@ -140,8 +144,10 @@ await _project.Services.GetRequiredService() 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 dry run applies the CRDT side to a throwaway copy, so direction B reads back the same state a + // real sync would — the predicted fwdata changes now match too (previously they were garbage because + // the dry run left the CRDT empty and tried to delete everything from fwdata). + dryRunSyncResult.FwdataChanges.Should().Be(syncResult.FwdataChanges); } [Fact] diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index 588803caba..d1789799d8 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -11,7 +11,8 @@ namespace FwLiteProjectSync; public class CrdtFwdataProjectSyncService(MiniLcmImport miniLcmImport, ILogger logger, - MiniLcmApiValidationWrapperFactory validationWrapperFactory) + MiniLcmApiValidationWrapperFactory validationWrapperFactory, + CrdtProjectsService crdtProjectsService) { public record DryRunSyncResult( int CrdtChanges, @@ -63,19 +64,30 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc // No write normalization: Data is already normalised on both sides. // No query normalization: The sync doesn't do any querying. - crdtApi = validationWrapperFactory.Create(crdtApi); - fwdataApi = validationWrapperFactory.Create(fwdataApi); + // A dry run must report exactly what a real sync would do, on both sides, without persisting. + // The catch: the sync writes the CRDT then reads it back within the same pass (direction B reads + // the CRDT after direction A wrote it), which a record-only wrapper can't satisfy. So for the CRDT + // we run the real sync against a throwaway copy of its database — writes really apply and read back + // faithfully — and record what we applied. FwData is read once up front (never read back) and its + // file must not change, so it stays a record-only DryRunMiniLcmApi. + await using var crdtCopy = dryRun ? await crdtProjectsService.OpenProjectCopy(crdt.Project) : null; if (dryRun) { - crdtApi = new DryRunMiniLcmApi(crdtApi); - fwdataApi = new DryRunMiniLcmApi(fwdataApi); + crdt = (CrdtMiniLcmApi)crdtCopy!.Api; + crdtApi = new RecordingMiniLcmApi(validationWrapperFactory.Create(crdt)); + fwdataApi = new DryRunMiniLcmApi(validationWrapperFactory.Create(fwdataApi)); + } + else + { + crdtApi = validationWrapperFactory.Create(crdtApi); + fwdataApi = validationWrapperFactory.Create(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); + var syncedIdCount = 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 []. @@ -147,17 +159,17 @@ private async Task SyncInternal(IMiniLcmApi crdtApi, IMiniLcmApi fwd private void LogDryRun(IMiniLcmApi api, string type) { - if (api is not DryRunMiniLcmApi dryRunApi) return; - foreach (var dryRunRecord in dryRunApi.DryRunRecords) + if (api is not IDryRunRecorder recorder) return; + foreach (var dryRunRecord in recorder.DryRunRecords) { 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.DryRunRecords.Count}"); } private List GetDryRunRecords(IMiniLcmApi api) { - return ((DryRunMiniLcmApi)api).DryRunRecords; + return ((IDryRunRecorder)api).DryRunRecords; } } 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 index 61ea258c89..076fb415f6 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs @@ -5,7 +5,7 @@ namespace FwLiteProjectSync; -public partial class DryRunMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi +public partial class DryRunMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi, IDryRunRecorder { [BeaKona.AutoInterface(typeof(IMiniLcmReadApi), MemberMatch = BeaKona.MemberMatchTypes.Any)] private readonly IMiniLcmApi _api = api; diff --git a/backend/FwLite/FwLiteProjectSync/IDryRunRecorder.cs b/backend/FwLite/FwLiteProjectSync/IDryRunRecorder.cs new file mode 100644 index 0000000000..bae0429403 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync/IDryRunRecorder.cs @@ -0,0 +1,11 @@ +namespace FwLiteProjectSync; + +/// +/// Implemented by the dry-run api wrappers so the sync service can pull out the recorded "what would +/// change" list regardless of which wrapper (record-only or record-and-apply +/// ) was used. +/// +public interface IDryRunRecorder +{ + List DryRunRecords { get; } +} diff --git a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs new file mode 100644 index 0000000000..ec337a0da7 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs @@ -0,0 +1,223 @@ +using MiniLcm; +using MiniLcm.Models; +using MiniLcm.SyncHelpers; + +namespace FwLiteProjectSync; + +/// +/// Dry-run wrapper for the CRDT side of a sync. Unlike (which records writes +/// but doesn't apply them), this really applies every write to the wrapped api and records it. The wrapped api +/// is a throwaway copy of the project (see ), so the +/// sync's read-back of its own writes is faithful without touching the real project. +/// +/// Reads, Submit*, and any write not overridden here are auto-forwarded to the copy by AutoInterface, so +/// the class is correct even where it doesn't record — the overrides only add the human-readable record entries. +/// +public partial class RecordingMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi, IDryRunRecorder +{ + [BeaKona.AutoInterface(IncludeBaseInterfaces = true, MemberMatch = BeaKona.MemberMatchTypes.Any)] + private readonly IMiniLcmApi _api = api; + + public List DryRunRecords { get; } = []; + + // Don't dispose the wrapped api: it belongs to the copy's service scope, which is disposed by the + // TempCrdtProjectCopy handle. + public void Dispose() { } + + private Task Record(string method, string description, Task operation) + { + DryRunRecords.Add(new DryRunMiniLcmApi.DryRunRecord(method, description)); + return operation; + } + + private Task Record(string method, string description, Task operation) + { + DryRunRecords.Add(new DryRunMiniLcmApi.DryRunRecord(method, description)); + return operation; + } + + #region WritingSystem + public Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? between = null) => + Record(nameof(CreateWritingSystem), $"Create writing system {writingSystem.Type} {writingSystem.WsId}", _api.CreateWritingSystem(writingSystem, between)); + + public Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) => + Record(nameof(UpdateWritingSystem), $"Update writing system {type} {id}", _api.UpdateWritingSystem(id, type, update)); + + public Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api = null) => + Record(nameof(UpdateWritingSystem), $"Update writing system {after.Type} {after.WsId}", _api.UpdateWritingSystem(before, after, api)); + + public Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) => + Record(nameof(MoveWritingSystem), $"Move writing system {type} {id}", _api.MoveWritingSystem(id, type, between)); + #endregion + + #region PartOfSpeech + public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) => + Record(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Id}", _api.CreatePartOfSpeech(partOfSpeech)); + + public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) => + Record(nameof(UpdatePartOfSpeech), $"Update part of speech {id}", _api.UpdatePartOfSpeech(id, update)); + + public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api = null) => + Record(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}", _api.UpdatePartOfSpeech(before, after, api)); + + public Task DeletePartOfSpeech(Guid id) => + Record(nameof(DeletePartOfSpeech), $"Delete part of speech {id}", _api.DeletePartOfSpeech(id)); + #endregion + + #region Publication + public Task CreatePublication(Publication pub) => + Record(nameof(CreatePublication), $"Create publication {pub.Id}", _api.CreatePublication(pub)); + + public Task UpdatePublication(Guid id, UpdateObjectInput update) => + Record(nameof(UpdatePublication), $"Update publication {id}", _api.UpdatePublication(id, update)); + + public Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) => + Record(nameof(UpdatePublication), $"Update publication {after.Id}", _api.UpdatePublication(before, after, api)); + + public Task DeletePublication(Guid id) => + Record(nameof(DeletePublication), $"Delete publication {id}", _api.DeletePublication(id)); + #endregion + + #region SemanticDomain + public Task CreateSemanticDomain(SemanticDomain semanticDomain) => + Record(nameof(CreateSemanticDomain), $"Create semantic domain {semanticDomain.Id}", _api.CreateSemanticDomain(semanticDomain)); + + public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) => + Record(nameof(UpdateSemanticDomain), $"Update semantic domain {id}", _api.UpdateSemanticDomain(id, update)); + + public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api = null) => + Record(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}", _api.UpdateSemanticDomain(before, after, api)); + + public Task DeleteSemanticDomain(Guid id) => + Record(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}", _api.DeleteSemanticDomain(id)); + #endregion + + #region ComplexFormType + public Task CreateComplexFormType(ComplexFormType complexFormType) => + Record(nameof(CreateComplexFormType), $"Create complex form type {complexFormType.Id}", _api.CreateComplexFormType(complexFormType)); + + public Task UpdateComplexFormType(Guid id, UpdateObjectInput update) => + Record(nameof(UpdateComplexFormType), $"Update complex form type {id}", _api.UpdateComplexFormType(id, update)); + + public Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api = null) => + Record(nameof(UpdateComplexFormType), $"Update complex form type {after.Id}", _api.UpdateComplexFormType(before, after, api)); + + public Task DeleteComplexFormType(Guid id) => + Record(nameof(DeleteComplexFormType), $"Delete complex form type {id}", _api.DeleteComplexFormType(id)); + #endregion + + #region MorphType + public Task CreateMorphType(MorphType morphType) => + Record(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})", _api.CreateMorphType(morphType)); + + public Task UpdateMorphType(Guid id, UpdateObjectInput update) => + Record(nameof(UpdateMorphType), $"Update morph type {id}", _api.UpdateMorphType(id, update)); + + public Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api = null) => + Record(nameof(UpdateMorphType), $"Update morph type {after.Id}", _api.UpdateMorphType(before, after, api)); + #endregion + + #region Entry + public Task CreateEntry(Entry entry, CreateEntryOptions? options = null) => + Record(nameof(CreateEntry), $"Create entry {entry.Id}", _api.CreateEntry(entry, options)); + + public Task UpdateEntry(Guid id, UpdateObjectInput update) => + Record(nameof(UpdateEntry), $"Update entry {id}", _api.UpdateEntry(id, update)); + + public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api = null) => + Record(nameof(UpdateEntry), $"Update entry {after.Id}", _api.UpdateEntry(before, after, api)); + + public Task DeleteEntry(Guid id) => + Record(nameof(DeleteEntry), $"Delete entry {id}", _api.DeleteEntry(id)); + + public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) => + Record(nameof(CreateComplexFormComponent), $"Create complex form component {complexFormComponent.ComplexFormEntryId} -> {complexFormComponent.ComponentEntryId}", _api.CreateComplexFormComponent(complexFormComponent, position)); + + public Task MoveComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition between) => + Record(nameof(MoveComplexFormComponent), $"Move complex form component {complexFormComponent.ComplexFormEntryId} -> {complexFormComponent.ComponentEntryId}", _api.MoveComplexFormComponent(complexFormComponent, between)); + + public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) => + Record(nameof(DeleteComplexFormComponent), $"Delete complex form component {complexFormComponent.ComplexFormEntryId} -> {complexFormComponent.ComponentEntryId}", _api.DeleteComplexFormComponent(complexFormComponent)); + + public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) => + Record(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId} to entry {entryId}", _api.AddComplexFormType(entryId, complexFormTypeId)); + + public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) => + Record(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId} from entry {entryId}", _api.RemoveComplexFormType(entryId, complexFormTypeId)); + + public Task AddPublication(Guid entryId, Guid publicationId) => + Record(nameof(AddPublication), $"Add publication {publicationId} to entry {entryId}", _api.AddPublication(entryId, publicationId)); + + public Task RemovePublication(Guid entryId, Guid publicationId) => + Record(nameof(RemovePublication), $"Remove publication {publicationId} from entry {entryId}", _api.RemovePublication(entryId, publicationId)); + #endregion + + #region Sense + public Task CreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) => + Record(nameof(CreateSense), $"Create sense {sense.Id} on entry {entryId}", _api.CreateSense(entryId, sense, position)); + + public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) => + Record(nameof(UpdateSense), $"Update sense {senseId}", _api.UpdateSense(entryId, senseId, update)); + + public Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api = null) => + Record(nameof(UpdateSense), $"Update sense {after.Id}", _api.UpdateSense(entryId, before, after, api)); + + public Task MoveSense(Guid entryId, Guid senseId, BetweenPosition position) => + Record(nameof(MoveSense), $"Move sense {senseId}", _api.MoveSense(entryId, senseId, position)); + + public Task DeleteSense(Guid entryId, Guid senseId) => + Record(nameof(DeleteSense), $"Delete sense {senseId}", _api.DeleteSense(entryId, senseId)); + + public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) => + Record(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Id} to sense {senseId}", _api.AddSemanticDomainToSense(senseId, semanticDomain)); + + public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) => + Record(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId} from sense {senseId}", _api.RemoveSemanticDomainFromSense(senseId, semanticDomainId)); + + public Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) => + Record(nameof(SetSensePartOfSpeech), $"Set part of speech {partOfSpeechId} on sense {senseId}", _api.SetSensePartOfSpeech(senseId, partOfSpeechId)); + #endregion + + #region ExampleSentence + public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) => + Record(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Id} on sense {senseId}", _api.CreateExampleSentence(entryId, senseId, exampleSentence, position)); + + public Task UpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput update) => + Record(nameof(UpdateExampleSentence), $"Update example sentence {exampleSentenceId}", _api.UpdateExampleSentence(entryId, senseId, exampleSentenceId, update)); + + public Task UpdateExampleSentence(Guid entryId, Guid senseId, ExampleSentence before, ExampleSentence after, IMiniLcmApi? api = null) => + Record(nameof(UpdateExampleSentence), $"Update example sentence {after.Id}", _api.UpdateExampleSentence(entryId, senseId, before, after, api)); + + public Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, BetweenPosition position) => + Record(nameof(MoveExampleSentence), $"Move example sentence {exampleSentenceId}", _api.MoveExampleSentence(entryId, senseId, exampleSentenceId, position)); + + public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) => + Record(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}", _api.DeleteExampleSentence(entryId, senseId, exampleSentenceId)); + + public Task AddTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Translation translation) => + Record(nameof(AddTranslation), $"Add translation {translation.Id} to example sentence {exampleSentenceId}", _api.AddTranslation(entryId, senseId, exampleSentenceId, translation)); + + public Task RemoveTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId) => + Record(nameof(RemoveTranslation), $"Remove translation {translationId} from example sentence {exampleSentenceId}", _api.RemoveTranslation(entryId, senseId, exampleSentenceId, translationId)); + + public Task UpdateTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId, UpdateObjectInput update) => + Record(nameof(UpdateTranslation), $"Update translation {translationId} in example sentence {exampleSentenceId}", _api.UpdateTranslation(entryId, senseId, exampleSentenceId, translationId, update)); + #endregion + + #region Picture + public Task CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? position = null) => + Record(nameof(CreatePicture), $"Create picture {picture.Id} on sense {senseId}", _api.CreatePicture(entryId, senseId, picture, position)); + + public Task UpdatePicture(Guid entryId, Guid senseId, Guid pictureId, UpdateObjectInput update) => + Record(nameof(UpdatePicture), $"Update picture {pictureId}", _api.UpdatePicture(entryId, senseId, pictureId, update)); + + public Task UpdatePicture(Guid entryId, Guid senseId, Picture before, Picture after, IMiniLcmApi? api = null) => + Record(nameof(UpdatePicture), $"Update picture {after.Id}", _api.UpdatePicture(entryId, senseId, before, after, api)); + + public Task MovePicture(Guid entryId, Guid senseId, Guid pictureId, BetweenPosition position) => + Record(nameof(MovePicture), $"Move picture {pictureId}", _api.MovePicture(entryId, senseId, pictureId, position)); + + public Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) => + Record(nameof(DeletePicture), $"Delete picture {pictureId}", _api.DeletePicture(entryId, senseId, pictureId)); + #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..9c106138d1 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -284,6 +284,32 @@ public virtual async Task CreateProject(CreateProjectRequest reques return crdtProject; } + /// + /// Opens a throwaway copy of an existing project's database in its own service scope. The copy lives in + /// a temp directory (not ), so it never shows up in . + /// Used by dry-run sync: changes can be really applied to (and read back from) the copy without touching + /// the original. Dispose the returned handle to close the copy and delete its temp files. + /// + public async Task OpenProjectCopy(CrdtProject source) + { + var tempDir = Path.Combine(Path.GetTempPath(), "FwLiteProjectCopies"); + Directory.CreateDirectory(tempDir); + var tempPath = Path.Combine(tempDir, $"{source.Name}-{Guid.NewGuid():N}.sqlite"); + + 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(); + // WAL-safe snapshot: the online backup API reads the source's latest committed state. + sourceConnection.BackupDatabase(copyConnection); + } + + var scope = provider.CreateAsyncScope(); + var api = await scope.ServiceProvider.OpenCrdtProject(new CrdtProject(source.Name, tempPath)); + return new TempCrdtProjectCopy(api, scope, () => EnsureDeleteProject(tempPath, suppressException: true)); + } + 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..137038bb0f --- /dev/null +++ b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace LcmCrdt; + +/// +/// A disposable, throwaway copy of a CRDT project's database opened in its own service scope +/// (see ). Used by dry-run sync so changes can be +/// really applied and read back without touching the original project. Disposing closes the copy's +/// scope and deletes its temporary database files. +/// +public sealed class TempCrdtProjectCopy(IMiniLcmApi api, AsyncServiceScope scope, Func deleteFiles) : IAsyncDisposable +{ + public IMiniLcmApi Api { get; } = api; + + public async ValueTask DisposeAsync() + { + await scope.DisposeAsync(); + await deleteFiles(); + } +} From f8fc742a7b0d7d446c302357f4779c4d796600fa Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 17:09:00 +0200 Subject: [PATCH 02/12] Unify the two dry-run wrappers around a single recorder Recording lived in two places: the old DryRunMiniLcmApi (record + swallow) and the new RecordingMiniLcmApi (record + apply), duplicating ~50 write descriptions. Split the two concerns instead: RecordingMiniLcmApi is the single recorder, and what its inner api does decides whether writes take effect. - ReadonlyMiniLcmApi (renamed from DryRunMiniLcmApi, recording removed) just discards writes and returns a plausible value; reads pass through. - Both dry-run sides are now RecordingMiniLcmApi(inner): the CRDT side wraps the real throwaway copy (writes apply), the fwdata side wraps a ReadonlyMiniLcmApi (writes discarded). Record strings exist once. - DryRunRecord lifted to a standalone type; IDryRunRecorder dropped (only one recorder). Co-Authored-By: Claude Opus 4.8 --- .../CrdtFwdataProjectSyncService.cs | 16 +-- .../FwLite/FwLiteProjectSync/DryRunRecord.cs | 4 + .../FwLiteProjectSync/IDryRunRecorder.cs | 11 -- ...RunMiniLcmApi.cs => ReadonlyMiniLcmApi.cs} | 131 +++--------------- .../FwLiteProjectSync/RecordingMiniLcmApi.cs | 21 +-- 5 files changed, 44 insertions(+), 139 deletions(-) create mode 100644 backend/FwLite/FwLiteProjectSync/DryRunRecord.cs delete mode 100644 backend/FwLite/FwLiteProjectSync/IDryRunRecorder.cs rename backend/FwLite/FwLiteProjectSync/{DryRunMiniLcmApi.cs => ReadonlyMiniLcmApi.cs} (52%) diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index d1789799d8..247c9ea2f4 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -17,8 +17,8 @@ public class CrdtFwdataProjectSyncService(MiniLcmImport miniLcmImport, 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) { @@ -69,14 +69,14 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc // The catch: the sync writes the CRDT then reads it back within the same pass (direction B reads // the CRDT after direction A wrote it), which a record-only wrapper can't satisfy. So for the CRDT // we run the real sync against a throwaway copy of its database — writes really apply and read back - // faithfully — and record what we applied. FwData is read once up front (never read back) and its - // file must not change, so it stays a record-only DryRunMiniLcmApi. + // faithfully. FwData is read once up front (never read back) and its file must not change, so it + // wraps a ReadonlyMiniLcmApi that discards writes. RecordingMiniLcmApi records both sides. await using var crdtCopy = dryRun ? await crdtProjectsService.OpenProjectCopy(crdt.Project) : null; if (dryRun) { crdt = (CrdtMiniLcmApi)crdtCopy!.Api; crdtApi = new RecordingMiniLcmApi(validationWrapperFactory.Create(crdt)); - fwdataApi = new DryRunMiniLcmApi(validationWrapperFactory.Create(fwdataApi)); + fwdataApi = new RecordingMiniLcmApi(new ReadonlyMiniLcmApi(validationWrapperFactory.Create(fwdataApi))); } else { @@ -159,7 +159,7 @@ private async Task SyncInternal(IMiniLcmApi crdtApi, IMiniLcmApi fwd private void LogDryRun(IMiniLcmApi api, string type) { - if (api is not IDryRunRecorder recorder) return; + if (api is not RecordingMiniLcmApi recorder) return; foreach (var dryRunRecord in recorder.DryRunRecords) { logger.LogInformation($"Dry run {type} record: {dryRunRecord.Method} {dryRunRecord.Description}"); @@ -168,8 +168,8 @@ private void LogDryRun(IMiniLcmApi api, string type) logger.LogInformation($"Dry run {type} changes: {recorder.DryRunRecords.Count}"); } - private List GetDryRunRecords(IMiniLcmApi api) + private List GetDryRunRecords(IMiniLcmApi api) { - return ((IDryRunRecorder)api).DryRunRecords; + return ((RecordingMiniLcmApi)api).DryRunRecords; } } diff --git a/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs b/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs new file mode 100644 index 0000000000..b8dd1c1e27 --- /dev/null +++ b/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs @@ -0,0 +1,4 @@ +namespace FwLiteProjectSync; + +/// A human-readable record of a single write a dry-run sync would perform. +public record DryRunRecord(string Method, string Description); diff --git a/backend/FwLite/FwLiteProjectSync/IDryRunRecorder.cs b/backend/FwLite/FwLiteProjectSync/IDryRunRecorder.cs deleted file mode 100644 index bae0429403..0000000000 --- a/backend/FwLite/FwLiteProjectSync/IDryRunRecorder.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace FwLiteProjectSync; - -/// -/// Implemented by the dry-run api wrappers so the sync service can pull out the recorded "what would -/// change" list regardless of which wrapper (record-only or record-and-apply -/// ) was used. -/// -public interface IDryRunRecorder -{ - List DryRunRecords { get; } -} diff --git a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs similarity index 52% rename from backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs rename to backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs index 076fb415f6..79d1b8efc3 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs @@ -5,7 +5,14 @@ namespace FwLiteProjectSync; -public partial class DryRunMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi, IDryRunRecorder +/// +/// A write-swallowing view of an api: reads pass through to the wrapped api, but writes are discarded and +/// return a plausible value (the input, or the current state) instead of being applied. Used as the inner +/// api for the fwdata side of a dry run — wrapped by , which records what +/// each write would have done. The fwdata file must not change and is never read back mid-sync, so swallowing +/// its writes is exactly what a dry run wants. +/// +public partial class ReadonlyMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi { [BeaKona.AutoInterface(typeof(IMiniLcmReadApi), MemberMatch = BeaKona.MemberMatchTypes.Any)] private readonly IMiniLcmApi _api = api; @@ -14,14 +21,8 @@ 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); } @@ -29,8 +30,6 @@ 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 { @@ -42,112 +41,92 @@ public async Task UpdateWritingSystem(WritingSystemId 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) + public 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; + return 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 + return Task.FromResult(partOfSpeech); // Since writes are swallowed, 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); @@ -157,37 +136,31 @@ public Task CreateEntry(Entry entry, CreateEntryOptions? options) 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) + public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) { - DryRunRecords.Add(new DryRunRecord(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId}, from entry {entryId}")); - await Task.CompletedTask; + return 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); @@ -196,44 +169,36 @@ public async Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInp 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); } @@ -242,8 +207,6 @@ public async Task UpdateExampleSentence(Guid entryId, 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}"); } @@ -254,31 +217,26 @@ public Task UpdateExampleSentence(Guid entryId, 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; } @@ -288,14 +246,12 @@ public Task UpdateTranslation(Guid entryId, 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); } @@ -304,8 +260,6 @@ public async Task UpdatePicture(Guid entryId, 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}"); } @@ -316,167 +270,124 @@ public Task UpdatePicture(Guid entryId, 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) + public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) { - DryRunRecords.Add(new DryRunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); - await Task.CompletedTask; + return Task.CompletedTask; } - public async Task CreatePublication(Publication pub) + public Task CreatePublication(Publication pub) { - DryRunRecords.Add(new DryRunRecord(nameof(CreatePublication), $"Create publication {pub.Id}")); - return await Task.FromResult(pub); + return 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. + // 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) { diff --git a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs index ec337a0da7..66306c1b3a 100644 --- a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs @@ -5,20 +5,21 @@ namespace FwLiteProjectSync; /// -/// Dry-run wrapper for the CRDT side of a sync. Unlike (which records writes -/// but doesn't apply them), this really applies every write to the wrapped api and records it. The wrapped api -/// is a throwaway copy of the project (see ), so the -/// sync's read-back of its own writes is faithful without touching the real project. +/// Records each write and forwards it to the wrapped api. Used for both sides of a dry-run sync, differing only +/// in what it wraps: the CRDT side wraps a throwaway copy of the project (see +/// ), so writes really apply and the sync's read-back of +/// its own writes is faithful; the fwdata side wraps a , so writes are recorded +/// but discarded (its file must not change). /// -/// Reads, Submit*, and any write not overridden here are auto-forwarded to the copy by AutoInterface, so -/// the class is correct even where it doesn't record — the overrides only add the human-readable record entries. +/// Reads, Submit*, and any write not overridden here are auto-forwarded by AutoInterface, so the class is +/// correct even where it doesn't record — the overrides only add the human-readable record entries. /// -public partial class RecordingMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi, IDryRunRecorder +public partial class RecordingMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi { [BeaKona.AutoInterface(IncludeBaseInterfaces = true, MemberMatch = BeaKona.MemberMatchTypes.Any)] private readonly IMiniLcmApi _api = api; - public List DryRunRecords { get; } = []; + public List DryRunRecords { get; } = []; // Don't dispose the wrapped api: it belongs to the copy's service scope, which is disposed by the // TempCrdtProjectCopy handle. @@ -26,13 +27,13 @@ public void Dispose() { } private Task Record(string method, string description, Task operation) { - DryRunRecords.Add(new DryRunMiniLcmApi.DryRunRecord(method, description)); + DryRunRecords.Add(new DryRunRecord(method, description)); return operation; } private Task Record(string method, string description, Task operation) { - DryRunRecords.Add(new DryRunMiniLcmApi.DryRunRecord(method, description)); + DryRunRecords.Add(new DryRunRecord(method, description)); return operation; } From f500b216ba0a1164d978c58f9107f2b29fc55efd Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 17:19:42 +0200 Subject: [PATCH 03/12] Use the codebase's idiomatic read-only AutoInterface form for ReadonlyMiniLcmApi Match MiniLcmApiWriteNormalizationWrapper: forward IMiniLcmReadApi via a typed [AutoInterface] property (interface inferred from the property type) instead of a typeof + MemberMatch=Any field. Writes stay manual so the compiler enforces every one is handled. The explicit GetEntries workaround is removed: it guarded the CRDT import destination against a missing writing system, but this class only ever wraps fwdata (which always has writing systems), so it was dead here and the reason MemberMatch was needed. Co-Authored-By: Claude Opus 4.8 --- .../FwLiteProjectSync/ReadonlyMiniLcmApi.cs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs index 79d1b8efc3..1ce81ca79e 100644 --- a/backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs @@ -14,9 +14,14 @@ namespace FwLiteProjectSync; /// public partial class ReadonlyMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi { - [BeaKona.AutoInterface(typeof(IMiniLcmReadApi), MemberMatch = BeaKona.MemberMatchTypes.Any)] private readonly IMiniLcmApi _api = api; + // BeaKona forwards IMiniLcmReadApi members to _api (interface inferred from the property type). Writes are + // deliberately NOT auto-forwarded — that would apply them — so each is implemented below to swallow the + // write, and the compiler enforces that every one is handled because IMiniLcmWriteApi isn't generated. + [BeaKona.AutoInterface] + private IMiniLcmReadApi ReadApi => _api; + public void Dispose() { } @@ -387,16 +392,4 @@ public Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput IMiniLcmReadApi.GetEntries(QueryOptions? options) - { - if (await _api.CountEntries() > 0) - { - await foreach (var entry in _api.GetEntries(options)) - { - yield return entry; - } - } - } } From 090c399967acbb221b61e639c3c2a61dfa119d24 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 17:35:10 +0200 Subject: [PATCH 04/12] Match pre-split dry-run records exactly and forbid unrecorded writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the dry-run wrappers: - RecordingMiniLcmApi now carries the exact record strings the pre-split DryRunMiniLcmApi produced (verified identical), rather than the terser ad-hoc ones. Each write is the old Add line followed by a forward to the wrapped api. - Forward reads via the read-only [AutoInterface] property (as ReadonlyMiniLcmApi and the other wrappers do) instead of IncludeBaseInterfaces=true. Writes are no longer auto-forwarded, so the compiler enforces that every write is implemented here and thus recorded — nothing can slip through unrecorded (Submit* included). - Rename CrdtProjectsService.OpenProjectCopy to OpenTemporaryProjectCopy so the call site reads as temporary/disposable. CreateEntry forwards the original (possibly null) options — null means "add main publication" to the api, which new CreateEntryOptions() does not — while still logging the same string. Co-Authored-By: Claude Opus 4.8 --- .../CrdtFwdataProjectSyncService.cs | 2 +- .../FwLiteProjectSync/RecordingMiniLcmApi.cs | 540 +++++++++++++----- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 5 +- 3 files changed, 400 insertions(+), 147 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index 247c9ea2f4..a727f98d94 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -71,7 +71,7 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc // we run the real sync against a throwaway copy of its database — writes really apply and read back // faithfully. FwData is read once up front (never read back) and its file must not change, so it // wraps a ReadonlyMiniLcmApi that discards writes. RecordingMiniLcmApi records both sides. - await using var crdtCopy = dryRun ? await crdtProjectsService.OpenProjectCopy(crdt.Project) : null; + await using var crdtCopy = dryRun ? await crdtProjectsService.OpenTemporaryProjectCopy(crdt.Project) : null; if (dryRun) { crdt = (CrdtMiniLcmApi)crdtCopy!.Api; diff --git a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs index 66306c1b3a..96c35f61d1 100644 --- a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs @@ -7,218 +7,470 @@ namespace FwLiteProjectSync; /// /// Records each write and forwards it to the wrapped api. Used for both sides of a dry-run sync, differing only /// in what it wraps: the CRDT side wraps a throwaway copy of the project (see -/// ), so writes really apply and the sync's read-back of -/// its own writes is faithful; the fwdata side wraps a , so writes are recorded -/// but discarded (its file must not change). +/// ), so writes really apply and the sync's +/// read-back of its own writes is faithful; the fwdata side wraps a , so writes +/// are recorded but discarded (its file must not change). /// -/// Reads, Submit*, and any write not overridden here are auto-forwarded by AutoInterface, so the class is -/// correct even where it doesn't record — the overrides only add the human-readable record entries. +/// The record strings are kept identical to what the pre-split dry-run api produced. Reads are forwarded by +/// BeaKona; writes are NOT auto-forwarded, so the compiler enforces that every one is implemented here and thus +/// recorded — nothing can slip through unrecorded. /// public partial class RecordingMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi { - [BeaKona.AutoInterface(IncludeBaseInterfaces = true, MemberMatch = BeaKona.MemberMatchTypes.Any)] private readonly IMiniLcmApi _api = api; + [BeaKona.AutoInterface] + private IMiniLcmReadApi ReadApi => _api; + public List DryRunRecords { get; } = []; - // Don't dispose the wrapped api: it belongs to the copy's service scope, which is disposed by the - // TempCrdtProjectCopy handle. - public void Dispose() { } + // Don't dispose the wrapped api: it belongs to the caller (the CRDT copy is disposed by its + // TempCrdtProjectCopy handle; the fwdata api by whoever opened it). + public void Dispose() + { + } - private Task Record(string method, string description, Task operation) + public Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? position = null) { - DryRunRecords.Add(new DryRunRecord(method, description)); - return operation; + DryRunRecords.Add(new DryRunRecord(nameof(CreateWritingSystem), + $"Create writing system {writingSystem.Type} between {position?.Previous} and {position?.Next}")); + return _api.CreateWritingSystem(writingSystem, position); } - private Task Record(string method, string description, Task operation) + public Task UpdateWritingSystem(WritingSystemId id, + WritingSystemType type, + UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(method, description)); - return operation; + DryRunRecords.Add(new DryRunRecord(nameof(UpdateWritingSystem), + $"Update writing system {type}, changes: {update.Summarize()}")); + return _api.UpdateWritingSystem(id, type, update); } - #region WritingSystem - public Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? between = null) => - Record(nameof(CreateWritingSystem), $"Create writing system {writingSystem.Type} {writingSystem.WsId}", _api.CreateWritingSystem(writingSystem, between)); + public Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateWritingSystem), $"Update {after.Type} writing system {after.WsId}")); + return _api.UpdateWritingSystem(before, after, api); + } - public Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) => - Record(nameof(UpdateWritingSystem), $"Update writing system {type} {id}", _api.UpdateWritingSystem(id, type, update)); + public Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) + { + DryRunRecords.Add(new DryRunRecord(nameof(MoveWritingSystem), $"Move writing system {id} between {between.Previous} and {between.Next}")); + return _api.MoveWritingSystem(id, type, between); + } - public Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api = null) => - Record(nameof(UpdateWritingSystem), $"Update writing system {after.Type} {after.WsId}", _api.UpdateWritingSystem(before, after, api)); + public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) + { + DryRunRecords.Add(new DryRunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); + return _api.CreatePartOfSpeech(partOfSpeech); + } - public Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) => - Record(nameof(MoveWritingSystem), $"Move writing system {type} {id}", _api.MoveWritingSystem(id, type, between)); - #endregion + public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}")); + return _api.UpdatePartOfSpeech(id, update); + } - #region PartOfSpeech - public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) => - Record(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Id}", _api.CreatePartOfSpeech(partOfSpeech)); + public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}")); + return _api.UpdatePartOfSpeech(before, after, api); + } - public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) => - Record(nameof(UpdatePartOfSpeech), $"Update part of speech {id}", _api.UpdatePartOfSpeech(id, update)); + public Task DeletePartOfSpeech(Guid id) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}")); + return _api.DeletePartOfSpeech(id); + } - public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api = null) => - Record(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}", _api.UpdatePartOfSpeech(before, after, api)); + public Task CreateSemanticDomain(SemanticDomain semanticDomain) + { + DryRunRecords.Add(new DryRunRecord(nameof(CreateSemanticDomain), + $"Create semantic domain {semanticDomain.Name}")); + return _api.CreateSemanticDomain(semanticDomain); + } - public Task DeletePartOfSpeech(Guid id) => - Record(nameof(DeletePartOfSpeech), $"Delete part of speech {id}", _api.DeletePartOfSpeech(id)); - #endregion + public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {id}")); + return _api.UpdateSemanticDomain(id, update); + } - #region Publication - public Task CreatePublication(Publication pub) => - Record(nameof(CreatePublication), $"Create publication {pub.Id}", _api.CreatePublication(pub)); + public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}")); + return _api.UpdateSemanticDomain(before, after, api); + } - public Task UpdatePublication(Guid id, UpdateObjectInput update) => - Record(nameof(UpdatePublication), $"Update publication {id}", _api.UpdatePublication(id, update)); + public Task DeleteSemanticDomain(Guid id) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}")); + return _api.DeleteSemanticDomain(id); + } - public Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) => - Record(nameof(UpdatePublication), $"Update publication {after.Id}", _api.UpdatePublication(before, after, api)); + public Task CreateComplexFormType(ComplexFormType complexFormType) + { + DryRunRecords.Add(new DryRunRecord(nameof(CreateComplexFormType), + $"Create complex form type {complexFormType.Name}")); + return _api.CreateComplexFormType(complexFormType); + } - public Task DeletePublication(Guid id) => - Record(nameof(DeletePublication), $"Delete publication {id}", _api.DeletePublication(id)); - #endregion + public Task UpdateComplexFormType(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateComplexFormType), $"Update complex form type {id}")); + return _api.UpdateComplexFormType(id, update); + } - #region SemanticDomain - public Task CreateSemanticDomain(SemanticDomain semanticDomain) => - Record(nameof(CreateSemanticDomain), $"Create semantic domain {semanticDomain.Id}", _api.CreateSemanticDomain(semanticDomain)); + public Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateComplexFormType), $"Update complex form type {after.Id}")); + return _api.UpdateComplexFormType(before, after, api); + } - public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) => - Record(nameof(UpdateSemanticDomain), $"Update semantic domain {id}", _api.UpdateSemanticDomain(id, update)); + public Task DeleteComplexFormType(Guid id) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormType), $"Delete complex form type {id}")); + return _api.DeleteComplexFormType(id); + } - public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api = null) => - Record(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}", _api.UpdateSemanticDomain(before, after, api)); + public Task CreateMorphType(MorphType morphType) + { + DryRunRecords.Add(new DryRunRecord(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})")); + return _api.CreateMorphType(morphType); + } - public Task DeleteSemanticDomain(Guid id) => - Record(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}", _api.DeleteSemanticDomain(id)); - #endregion + public Task UpdateMorphType(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateMorphType), $"Update morph type {id}")); + return _api.UpdateMorphType(id, update); + } - #region ComplexFormType - public Task CreateComplexFormType(ComplexFormType complexFormType) => - Record(nameof(CreateComplexFormType), $"Create complex form type {complexFormType.Id}", _api.CreateComplexFormType(complexFormType)); + public Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateMorphType), $"Update morph type {after.Id}")); + return _api.UpdateMorphType(before, after, api); + } - public Task UpdateComplexFormType(Guid id, UpdateObjectInput update) => - Record(nameof(UpdateComplexFormType), $"Update complex form type {id}", _api.UpdateComplexFormType(id, update)); + public Task CreateEntry(Entry entry, CreateEntryOptions? options = null) + { + DryRunRecords.Add(new DryRunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options ?? new CreateEntryOptions()})")); + return _api.CreateEntry(entry, options); + } - public Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api = null) => - Record(nameof(UpdateComplexFormType), $"Update complex form type {after.Id}", _api.UpdateComplexFormType(before, after, api)); + public Task UpdateEntry(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateEntry), $"Update entry {id}")); + return _api.UpdateEntry(id, update); + } - public Task DeleteComplexFormType(Guid id) => - Record(nameof(DeleteComplexFormType), $"Delete complex form type {id}", _api.DeleteComplexFormType(id)); - #endregion + public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateEntry), $"Update entry {after.Id}")); + return _api.UpdateEntry(before, after, api); + } - #region MorphType - public Task CreateMorphType(MorphType morphType) => - Record(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})", _api.CreateMorphType(morphType)); + public Task DeleteEntry(Guid id) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeleteEntry), $"Delete entry {id}")); + return _api.DeleteEntry(id); + } - public Task UpdateMorphType(Guid id, UpdateObjectInput update) => - Record(nameof(UpdateMorphType), $"Update morph type {id}", _api.UpdateMorphType(id, update)); + public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) + { + DryRunRecords.Add(new DryRunRecord(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId}, from entry {entryId}")); + return _api.RemoveComplexFormType(entryId, complexFormTypeId); + } - public Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api = null) => - Record(nameof(UpdateMorphType), $"Update morph type {after.Id}", _api.UpdateMorphType(before, after, api)); - #endregion + 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 _api.CreateSense(entryId, sense, position); + } - #region Entry - public Task CreateEntry(Entry entry, CreateEntryOptions? options = null) => - Record(nameof(CreateEntry), $"Create entry {entry.Id}", _api.CreateEntry(entry, options)); + public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateSense), + $"Update sense {senseId}, changes: {update.Summarize()}")); + return _api.UpdateSense(entryId, senseId, update); + } - public Task UpdateEntry(Guid id, UpdateObjectInput update) => - Record(nameof(UpdateEntry), $"Update entry {id}", _api.UpdateEntry(id, update)); + public Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateSense), + $"Update sense {after.Id}")); + return _api.UpdateSense(entryId, before, after, api); + } - public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api = null) => - Record(nameof(UpdateEntry), $"Update entry {after.Id}", _api.UpdateEntry(before, after, api)); + 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 _api.MoveSense(entryId, senseId, between); + } - public Task DeleteEntry(Guid id) => - Record(nameof(DeleteEntry), $"Delete entry {id}", _api.DeleteEntry(id)); + public Task DeleteSense(Guid entryId, Guid senseId) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeleteSense), $"Delete sense {senseId}")); + return _api.DeleteSense(entryId, senseId); + } - public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) => - Record(nameof(CreateComplexFormComponent), $"Create complex form component {complexFormComponent.ComplexFormEntryId} -> {complexFormComponent.ComponentEntryId}", _api.CreateComplexFormComponent(complexFormComponent, position)); + public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + { + DryRunRecords.Add(new DryRunRecord(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Name}")); + return _api.AddSemanticDomainToSense(senseId, semanticDomain); + } - public Task MoveComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition between) => - Record(nameof(MoveComplexFormComponent), $"Move complex form component {complexFormComponent.ComplexFormEntryId} -> {complexFormComponent.ComponentEntryId}", _api.MoveComplexFormComponent(complexFormComponent, between)); + public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + { + DryRunRecords.Add(new DryRunRecord(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId}")); + return _api.RemoveSemanticDomainFromSense(senseId, semanticDomainId); + } - public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) => - Record(nameof(DeleteComplexFormComponent), $"Delete complex form component {complexFormComponent.ComplexFormEntryId} -> {complexFormComponent.ComponentEntryId}", _api.DeleteComplexFormComponent(complexFormComponent)); + public Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) + { + DryRunRecords.Add(new DryRunRecord(nameof(SetSensePartOfSpeech), $"Set part of speech {partOfSpeechId}")); + return _api.SetSensePartOfSpeech(senseId, partOfSpeechId); + } - public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) => - Record(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId} to entry {entryId}", _api.AddComplexFormType(entryId, complexFormTypeId)); + 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 _api.CreateExampleSentence(entryId, senseId, exampleSentence, position); + } - public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) => - Record(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId} from entry {entryId}", _api.RemoveComplexFormType(entryId, complexFormTypeId)); + public Task UpdateExampleSentence(Guid entryId, + Guid senseId, + Guid exampleSentenceId, + UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence), + $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); + return _api.UpdateExampleSentence(entryId, senseId, exampleSentenceId, update); + } - public Task AddPublication(Guid entryId, Guid publicationId) => - Record(nameof(AddPublication), $"Add publication {publicationId} to entry {entryId}", _api.AddPublication(entryId, publicationId)); + 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 _api.UpdateExampleSentence(entryId, senseId, before, after, api); + } - public Task RemovePublication(Guid entryId, Guid publicationId) => - Record(nameof(RemovePublication), $"Remove publication {publicationId} from entry {entryId}", _api.RemovePublication(entryId, publicationId)); - #endregion + 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 _api.MoveExampleSentence(entryId, senseId, exampleId, between); + } - #region Sense - public Task CreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) => - Record(nameof(CreateSense), $"Create sense {sense.Id} on entry {entryId}", _api.CreateSense(entryId, sense, position)); + public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}")); + return _api.DeleteExampleSentence(entryId, senseId, exampleSentenceId); + } - public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) => - Record(nameof(UpdateSense), $"Update sense {senseId}", _api.UpdateSense(entryId, senseId, update)); + 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 _api.AddTranslation(entryId, senseId, exampleSentenceId, translation); + } - public Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api = null) => - Record(nameof(UpdateSense), $"Update sense {after.Id}", _api.UpdateSense(entryId, before, after, api)); + 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 _api.RemoveTranslation(entryId, senseId, exampleSentenceId, translationId); + } - public Task MoveSense(Guid entryId, Guid senseId, BetweenPosition position) => - Record(nameof(MoveSense), $"Move sense {senseId}", _api.MoveSense(entryId, senseId, position)); + 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 _api.UpdateTranslation(entryId, senseId, exampleSentenceId, translationId, update); + } - public Task DeleteSense(Guid entryId, Guid senseId) => - Record(nameof(DeleteSense), $"Delete sense {senseId}", _api.DeleteSense(entryId, senseId)); - public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) => - Record(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Id} to sense {senseId}", _api.AddSemanticDomainToSense(senseId, semanticDomain)); + 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 _api.CreatePicture(entryId, senseId, picture, position); + } - public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) => - Record(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId} from sense {senseId}", _api.RemoveSemanticDomainFromSense(senseId, semanticDomainId)); + public Task UpdatePicture(Guid entryId, + Guid senseId, + Guid pictureId, + UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdatePicture), + $"Update picture {pictureId}, changes: {update.Summarize()}")); + return _api.UpdatePicture(entryId, senseId, pictureId, update); + } - public Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) => - Record(nameof(SetSensePartOfSpeech), $"Set part of speech {partOfSpeechId} on sense {senseId}", _api.SetSensePartOfSpeech(senseId, partOfSpeechId)); - #endregion + public Task UpdatePicture(Guid entryId, + Guid senseId, + Picture before, + Picture after, + IMiniLcmApi? api) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdatePicture), $"Update picture {after.Id}")); + return _api.UpdatePicture(entryId, senseId, before, after, api); + } + + 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 _api.MovePicture(entryId, senseId, exampleId, between); + } - #region ExampleSentence - public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) => - Record(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Id} on sense {senseId}", _api.CreateExampleSentence(entryId, senseId, exampleSentence, position)); + public Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeletePicture), $"Delete picture {pictureId}")); + return _api.DeletePicture(entryId, senseId, pictureId); + } - public Task UpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput update) => - Record(nameof(UpdateExampleSentence), $"Update example sentence {exampleSentenceId}", _api.UpdateExampleSentence(entryId, senseId, exampleSentenceId, update)); + 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 _api.CreateComplexFormComponent(complexFormComponent, between); + } - public Task UpdateExampleSentence(Guid entryId, Guid senseId, ExampleSentence before, ExampleSentence after, IMiniLcmApi? api = null) => - Record(nameof(UpdateExampleSentence), $"Update example sentence {after.Id}", _api.UpdateExampleSentence(entryId, senseId, before, after, api)); + 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 _api.MoveComplexFormComponent(complexFormComponent, between); + } - public Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, BetweenPosition position) => - Record(nameof(MoveExampleSentence), $"Move example sentence {exampleSentenceId}", _api.MoveExampleSentence(entryId, senseId, exampleSentenceId, position)); + public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) + { + var componentName = ComplexFormComponentName(complexFormComponent); + DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component: {componentName}")); + return _api.DeleteComplexFormComponent(complexFormComponent); + } - public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) => - Record(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}", _api.DeleteExampleSentence(entryId, senseId, exampleSentenceId)); + public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) + { + DryRunRecords.Add(new DryRunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); + return _api.AddComplexFormType(entryId, complexFormTypeId); + } - public Task AddTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Translation translation) => - Record(nameof(AddTranslation), $"Add translation {translation.Id} to example sentence {exampleSentenceId}", _api.AddTranslation(entryId, senseId, exampleSentenceId, translation)); + public Task CreatePublication(Publication pub) + { + DryRunRecords.Add(new DryRunRecord(nameof(CreatePublication), $"Create publication {pub.Id}")); + return _api.CreatePublication(pub); + } - public Task RemoveTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId) => - Record(nameof(RemoveTranslation), $"Remove translation {translationId} from example sentence {exampleSentenceId}", _api.RemoveTranslation(entryId, senseId, exampleSentenceId, translationId)); + public Task UpdatePublication(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdatePublication), $"Update publication {id}")); + return _api.UpdatePublication(id, update); + } - public Task UpdateTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId, UpdateObjectInput update) => - Record(nameof(UpdateTranslation), $"Update translation {translationId} in example sentence {exampleSentenceId}", _api.UpdateTranslation(entryId, senseId, exampleSentenceId, translationId, update)); - #endregion + public Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) + { + DryRunRecords.Add(new DryRunRecord(nameof(UpdatePublication), $"Update publication {before.Id}")); + return _api.UpdatePublication(before, after, api); + } + + public Task DeletePublication(Guid id) + { + DryRunRecords.Add(new DryRunRecord(nameof(DeletePublication), $"Delete publication {id}")); + return _api.DeletePublication(id); + } + + public Task AddPublication(Guid entryId, Guid publicationId) + { + DryRunRecords.Add(new DryRunRecord(nameof(AddPublication), $"Add publication {publicationId} to entry {entryId}")); + return _api.AddPublication(entryId, publicationId); + } + + public Task RemovePublication(Guid entryId, Guid publicationId) + { + DryRunRecords.Add(new DryRunRecord(nameof(RemovePublication), $"Remove publication {publicationId} from entry {entryId}")); + return _api.RemovePublication(entryId, publicationId); + } + + #region Submit (sync's result-less write variants) + // Forward to the wrapped api's Submit* so the CRDT side keeps its delete-wins behaviour (and the fwdata + // ReadonlyMiniLcmApi swallows it). The two Submit* the pre-split api left unimplemented still default to + // the returning Move*/Update* above, which record. + public Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}")); + return _api.SubmitUpdateEntry(id, update); + } + + public Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}")); + return _api.SubmitUpdateSense(entryId, senseId, update); + } + + 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 _api.SubmitUpdateExampleSentence(entryId, senseId, exampleSentenceId, update); + } + + public Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateSense), $"Create sense {sense.Gloss}")); + return _api.SubmitCreateSense(entryId, sense, position); + } - #region Picture - public Task CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? position = null) => - Record(nameof(CreatePicture), $"Create picture {picture.Id} on sense {senseId}", _api.CreatePicture(entryId, senseId, picture, position)); + public Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); + return _api.SubmitCreateExampleSentence(entryId, senseId, exampleSentence, position); + } - public Task UpdatePicture(Guid entryId, Guid senseId, Guid pictureId, UpdateObjectInput update) => - Record(nameof(UpdatePicture), $"Update picture {pictureId}", _api.UpdatePicture(entryId, senseId, pictureId, update)); + public Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateComplexFormComponent), $"Create complex form component {ComplexFormComponentName(complexFormComponent)}")); + return _api.SubmitCreateComplexFormComponent(complexFormComponent, position); + } - public Task UpdatePicture(Guid entryId, Guid senseId, Picture before, Picture after, IMiniLcmApi? api = null) => - Record(nameof(UpdatePicture), $"Update picture {after.Id}", _api.UpdatePicture(entryId, senseId, before, after, api)); + public Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePartOfSpeech), $"Update part of speech {id}")); + return _api.SubmitUpdatePartOfSpeech(id, update); + } - public Task MovePicture(Guid entryId, Guid senseId, Guid pictureId, BetweenPosition position) => - Record(nameof(MovePicture), $"Move picture {pictureId}", _api.MovePicture(entryId, senseId, pictureId, position)); + public Task SubmitUpdatePublication(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePublication), $"Update publication {id}")); + return _api.SubmitUpdatePublication(id, update); + } - public Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) => - Record(nameof(DeletePicture), $"Delete picture {pictureId}", _api.DeletePicture(entryId, senseId, pictureId)); + public Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSemanticDomain), $"Update semantic domain {id}")); + return _api.SubmitUpdateSemanticDomain(id, update); + } + + public Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput update) + { + DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateComplexFormType), $"Update complex form type {id}")); + return _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/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 9c106138d1..c86dc2f95d 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -288,9 +288,10 @@ public virtual async Task CreateProject(CreateProjectRequest reques /// Opens a throwaway copy of an existing project's database in its own service scope. The copy lives in /// a temp directory (not ), so it never shows up in . /// Used by dry-run sync: changes can be really applied to (and read back from) the copy without touching - /// the original. Dispose the returned handle to close the copy and delete its temp files. + /// the original. The returned is disposable — dispose it (e.g. with + /// await using) to close the copy and delete its temp files. /// - public async Task OpenProjectCopy(CrdtProject source) + public async Task OpenTemporaryProjectCopy(CrdtProject source) { var tempDir = Path.Combine(Path.GetTempPath(), "FwLiteProjectCopies"); Directory.CreateDirectory(tempDir); From 40bdd03033939c2729325b34cb9662f2d3665821 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 17:55:12 +0200 Subject: [PATCH 05/12] Rename ReadonlyMiniLcmApi to WriteIgnoringMiniLcmApi and tighten the dry-run wrappers Address review feedback: - Rename ReadonlyMiniLcmApi to WriteIgnoringMiniLcmApi. "Readonly" implies writes are rejected; this class accepts them and does nothing, returning a plausible value so the sync continues. - Type its wrapped api as IMiniLcmReadApi so it structurally can't forward a write, and drop the redundant _api field in favour of the primary-constructor parameter. The two before/after write overloads that shadow that parameter read via the ReadApi property instead. - RecordingMiniLcmApi keeps the read-only [AutoInterface] property: reads are forwarded and not recorded (only writes are), and because writes aren't auto-forwarded the compiler still forces every one to be implemented here and thus recorded. - Comment pass: drop an unverified "delete-wins" claim, correct a stale OpenProjectCopy cref, explain BackupDatabase as "not File.Copy" rather than an unverified WAL-mode detail, and trim the test comments to the gotcha they guard. Co-Authored-By: Claude Opus 4.8 --- .../FwLiteProjectSync.Tests/Sena3SyncTests.cs | 10 ++-- .../CrdtFwdataProjectSyncService.cs | 12 ++--- .../FwLiteProjectSync/RecordingMiniLcmApi.cs | 22 ++++---- ...niLcmApi.cs => WriteIgnoringMiniLcmApi.cs} | 54 +++++++++---------- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 3 +- backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs | 2 +- 6 files changed, 48 insertions(+), 55 deletions(-) rename backend/FwLite/FwLiteProjectSync/{ReadonlyMiniLcmApi.cs => WriteIgnoringMiniLcmApi.cs} (81%) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index e8e4729a1e..b4b137c207 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -120,9 +120,8 @@ public async Task DryRunImport_MakesTheSameChangesAsImport() [Trait("Category", "Integration")] public async Task DryRunSync_MakesNoChanges() { - // The snapshot's writing systems must match the CRDT's, otherwise the (now faithful) dry run tries to - // re-create writing systems the CRDT already has — a real sync would throw there too. Import them so - // both sides agree, the same way DryRunSync_MakesTheSameChangesAsSync does. + // The faithful dry run really applies the WS sync to the CRDT copy, so the snapshot's WSs must match the + // CRDT's or it throws re-creating existing ones — a real sync would too. Import them so both sides agree. await _project.Services.GetRequiredService() .ImportWritingSystems(_crdtApi, await _fwDataApi.GetWritingSystems()); var projectSnapshot = await CreateAndSaveMinimalSnapshot(withWritingSystems: true); @@ -144,9 +143,8 @@ await _project.Services.GetRequiredService() var dryRunSyncResult = await _syncService.SyncDryRun(_crdtApi, _fwDataApi, projectSnapshot); var syncResult = await _syncService.Sync(_crdtApi, _fwDataApi, projectSnapshot); dryRunSyncResult.CrdtChanges.Should().Be(syncResult.CrdtChanges); - // The dry run applies the CRDT side to a throwaway copy, so direction B reads back the same state a - // real sync would — the predicted fwdata changes now match too (previously they were garbage because - // the dry run left the CRDT empty and tried to delete everything from fwdata). + // Fwdata changes now match: the dry run applies the CRDT side to a throwaway copy, so direction B reads + // back the same state a real sync would (before, the empty CRDT made it predict deleting everything). dryRunSyncResult.FwdataChanges.Should().Be(syncResult.FwdataChanges); } diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index a727f98d94..a6c4cb52c7 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -66,17 +66,17 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc // No query normalization: The sync doesn't do any querying. // A dry run must report exactly what a real sync would do, on both sides, without persisting. - // The catch: the sync writes the CRDT then reads it back within the same pass (direction B reads - // the CRDT after direction A wrote it), which a record-only wrapper can't satisfy. So for the CRDT - // we run the real sync against a throwaway copy of its database — writes really apply and read back - // faithfully. FwData is read once up front (never read back) and its file must not change, so it - // wraps a ReadonlyMiniLcmApi that discards writes. RecordingMiniLcmApi records both sides. + // The catch: within one pass the sync writes the CRDT then reads it back (direction B reads the CRDT + // that direction A just wrote), which a record-only wrapper can't satisfy. So the CRDT side runs the + // real sync against a throwaway copy of its database — writes apply and read back faithfully. FwData is + // read once up front and never read back, and its file must not change, so it wraps a + // WriteIgnoringMiniLcmApi. RecordingMiniLcmApi records both sides. await using var crdtCopy = dryRun ? await crdtProjectsService.OpenTemporaryProjectCopy(crdt.Project) : null; if (dryRun) { crdt = (CrdtMiniLcmApi)crdtCopy!.Api; crdtApi = new RecordingMiniLcmApi(validationWrapperFactory.Create(crdt)); - fwdataApi = new RecordingMiniLcmApi(new ReadonlyMiniLcmApi(validationWrapperFactory.Create(fwdataApi))); + fwdataApi = new RecordingMiniLcmApi(new WriteIgnoringMiniLcmApi(validationWrapperFactory.Create(fwdataApi))); } else { diff --git a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs index 96c35f61d1..b9a9fc413d 100644 --- a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs @@ -5,20 +5,18 @@ namespace FwLiteProjectSync; /// -/// Records each write and forwards it to the wrapped api. Used for both sides of a dry-run sync, differing only -/// in what it wraps: the CRDT side wraps a throwaway copy of the project (see -/// ), so writes really apply and the sync's -/// read-back of its own writes is faithful; the fwdata side wraps a , so writes -/// are recorded but discarded (its file must not change). -/// -/// The record strings are kept identical to what the pre-split dry-run api produced. Reads are forwarded by -/// BeaKona; writes are NOT auto-forwarded, so the compiler enforces that every one is implemented here and thus -/// recorded — nothing can slip through unrecorded. +/// Records each write (for the dry-run report) and forwards it to the wrapped api. Used on both sides of a +/// dry-run sync, differing only in what it wraps: the CRDT side wraps a throwaway copy of the project (see +/// ) so writes really apply and the sync can +/// read its own writes back faithfully; the fwdata side wraps a so the +/// recorded writes never touch the file. Record strings match what the pre-split dry-run api produced. /// public partial class RecordingMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi { private readonly IMiniLcmApi _api = api; + // Reads are forwarded by BeaKona and not recorded (only writes are). Writes are NOT auto-forwarded, so the + // compiler forces each one to be implemented here — and thus recorded — rather than silently passing through. [BeaKona.AutoInterface] private IMiniLcmReadApi ReadApi => _api; @@ -398,9 +396,9 @@ public Task RemovePublication(Guid entryId, Guid publicationId) } #region Submit (sync's result-less write variants) - // Forward to the wrapped api's Submit* so the CRDT side keeps its delete-wins behaviour (and the fwdata - // ReadonlyMiniLcmApi swallows it). The two Submit* the pre-split api left unimplemented still default to - // the returning Move*/Update* above, which record. + // Submit* are writes, so (like every write) they're implemented here to record and forward. Any Submit* not + // listed falls back to the interface default, which routes to the returning Update*/Move* above — those + // record too, so it stays covered. public Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) { DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}")); diff --git a/backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs similarity index 81% rename from backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs rename to backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs index 1ce81ca79e..44efab683c 100644 --- a/backend/FwLite/FwLiteProjectSync/ReadonlyMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs @@ -6,21 +6,17 @@ namespace FwLiteProjectSync; /// -/// A write-swallowing view of an api: reads pass through to the wrapped api, but writes are discarded and -/// return a plausible value (the input, or the current state) instead of being applied. Used as the inner -/// api for the fwdata side of a dry run — wrapped by , which records what -/// each write would have done. The fwdata file must not change and is never read back mid-sync, so swallowing -/// its writes is exactly what a dry run wants. +/// Reads pass through to the wrapped api; writes are ignored and return a plausible value (the input, or the +/// current state) instead of being applied. Used as the inner api for the fwdata side of a dry run, wrapped by +/// : the fwdata file must not change and is never read back mid-sync, so a +/// write that leaves reads untouched is exactly what the dry run wants. /// -public partial class ReadonlyMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi +// The api is typed IMiniLcmReadApi so this class can't forward a write even by accident. Reads are forwarded by +// BeaKona; every write is implemented below (the compiler enforces it, since IMiniLcmWriteApi isn't generated). +public partial class WriteIgnoringMiniLcmApi(IMiniLcmReadApi api) : IMiniLcmApi { - private readonly IMiniLcmApi _api = api; - - // BeaKona forwards IMiniLcmReadApi members to _api (interface inferred from the property type). Writes are - // deliberately NOT auto-forwarded — that would apply them — so each is implemented below to swallow the - // write, and the compiler enforces that every one is handled because IMiniLcmWriteApi isn't generated. [BeaKona.AutoInterface] - private IMiniLcmReadApi ReadApi => _api; + private IMiniLcmReadApi ReadApi => api; public void Dispose() { @@ -35,7 +31,7 @@ public async Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) { - var ws = await _api.GetWritingSystems(); + var ws = await api.GetWritingSystems(); return (type switch { WritingSystemType.Vernacular => ws.Vernacular, @@ -56,12 +52,13 @@ public Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, Betwee public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) { - return Task.FromResult(partOfSpeech); // Since writes are swallowed, api.GetPartOfSpeech would return null + // Reads won't surface an ignored write, so return the input rather than re-reading. + return Task.FromResult(partOfSpeech); } public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) { - return _api.GetPartOfSpeech(id)!; + return api.GetPartOfSpeech(id)!; } public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) @@ -81,7 +78,7 @@ public Task CreateSemanticDomain(SemanticDomain semanticDomain) public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) { - return _api.GetSemanticDomain(id)!; + return api.GetSemanticDomain(id)!; } public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) @@ -101,7 +98,7 @@ public Task CreateComplexFormType(ComplexFormType complexFormTy 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}"); + 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) @@ -121,7 +118,7 @@ public Task CreateMorphType(MorphType 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}"); + 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) @@ -132,7 +129,6 @@ public Task UpdateMorphType(MorphType before, MorphType after, IMiniL public Task CreateEntry(Entry entry, CreateEntryOptions? options) { options ??= new CreateEntryOptions(); - // Only return what would have been persisted if (options.IncludeComplexFormsAndComponents) return Task.FromResult(entry); else @@ -141,7 +137,7 @@ public Task CreateEntry(Entry entry, CreateEntryOptions? options) public Task UpdateEntry(Guid id, UpdateObjectInput update) { - return _api.GetEntry(id)!; + return api.GetEntry(id)!; } public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) @@ -166,7 +162,7 @@ public Task CreateSense(Guid entryId, Sense sense, BetweenPosition? posit public async Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) { - var entry = await _api.GetEntry(entryId) ?? + 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; @@ -174,7 +170,7 @@ public async Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInp public async Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api) { - return await _api.GetSense(entryId, after.Id) ?? throw new NullReferenceException($"unable to find sense with id {after.Id}"); + 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) @@ -212,7 +208,7 @@ public async Task UpdateExampleSentence(Guid entryId, Guid exampleSentenceId, UpdateObjectInput update) { - var exampleSentence = await _api.GetExampleSentence(entryId, senseId, exampleSentenceId); + var exampleSentence = await api.GetExampleSentence(entryId, senseId, exampleSentenceId); return exampleSentence ?? throw new NullReferenceException($"unable to find example sentence with id {exampleSentenceId}"); } @@ -265,7 +261,7 @@ public async Task UpdatePicture(Guid entryId, Guid pictureId, UpdateObjectInput update) { - var picture = await _api.GetPicture(entryId, senseId, pictureId); + var picture = await api.GetPicture(entryId, senseId, pictureId); return picture ?? throw new NullReferenceException($"unable to find picture with id {pictureId}"); } @@ -315,12 +311,12 @@ public Task CreatePublication(Publication pub) public async Task UpdatePublication(Guid id, UpdateObjectInput update) { - return await _api.GetPublication(id) ?? throw NotFoundException.ForType(id); + return await api.GetPublication(id) ?? throw NotFoundException.ForType(id); } public async Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) { - return await _api.GetPublication(before.Id) ?? throw NotFoundException.ForType(before.Id); + return await ReadApi.GetPublication(before.Id) ?? throw NotFoundException.ForType(before.Id); } public Task DeletePublication(Guid id) @@ -339,9 +335,9 @@ public Task RemovePublication(Guid entryId, Guid publicationId) } #region Submit (sync's result-less write variants) - // 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. + // Implemented explicitly rather than falling back to the interface default, which routes to the returning + // Update* and re-reads the object — that would throw when a dry run of a conflicted project hits an object + // the other side has already deleted. public Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) { return Task.CompletedTask; diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index c86dc2f95d..4c0efdd113 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -302,7 +302,8 @@ public async Task OpenTemporaryProjectCopy(CrdtProject sour { await sourceConnection.OpenAsync(); await copyConnection.OpenAsync(); - // WAL-safe snapshot: the online backup API reads the source's latest committed state. + // Online backup API, not File.Copy: captures a consistent committed snapshot while the source is + // open, including any WAL sidecar files a plain file copy could miss. sourceConnection.BackupDatabase(copyConnection); } diff --git a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs index 137038bb0f..d57f99b2af 100644 --- a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs +++ b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs @@ -4,7 +4,7 @@ namespace LcmCrdt; /// /// A disposable, throwaway copy of a CRDT project's database opened in its own service scope -/// (see ). Used by dry-run sync so changes can be +/// (see ). Used by dry-run sync so changes can be /// really applied and read back without touching the original project. Disposing closes the copy's /// scope and deletes its temporary database files. /// From 7883275d6c418f7c324317c604c851c10517e8d8 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 18:08:33 +0200 Subject: [PATCH 06/12] Tighten dry-run wrapper comments and rename TempCrdtProjectCopy cleanup param - Rename TempCrdtProjectCopy's deleteFiles constructor param to cleanup (more generic). - Note in WriteIgnoringMiniLcmApi.CreateEntry why the IncludeComplexFormsAndComponents branch matters: it returns only what a real create would persist (this is where the fabricated return the old dry-run api produced now lives). - Trim every comment added on this branch to the fewest words that carry the point. Co-Authored-By: Claude Opus 4.8 --- .../FwLiteProjectSync.Tests/Sena3SyncTests.cs | 8 +++---- .../CrdtFwdataProjectSyncService.cs | 10 ++++----- .../FwLite/FwLiteProjectSync/DryRunRecord.cs | 2 +- .../FwLiteProjectSync/RecordingMiniLcmApi.cs | 21 ++++++++----------- .../WriteIgnoringMiniLcmApi.cs | 19 ++++++++--------- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 11 ++++------ backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs | 11 +++++----- 7 files changed, 36 insertions(+), 46 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index b4b137c207..451adc215c 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -120,8 +120,8 @@ public async Task DryRunImport_MakesTheSameChangesAsImport() [Trait("Category", "Integration")] public async Task DryRunSync_MakesNoChanges() { - // The faithful dry run really applies the WS sync to the CRDT copy, so the snapshot's WSs must match the - // CRDT's or it throws re-creating existing ones — a real sync would too. Import them so both sides agree. + // The dry run applies the WS sync to the CRDT copy, so snapshot WSs must match the CRDT's or it throws + // re-creating existing ones (a real sync would too). Import them so both sides agree. await _project.Services.GetRequiredService() .ImportWritingSystems(_crdtApi, await _fwDataApi.GetWritingSystems()); var projectSnapshot = await CreateAndSaveMinimalSnapshot(withWritingSystems: true); @@ -143,8 +143,8 @@ await _project.Services.GetRequiredService() var dryRunSyncResult = await _syncService.SyncDryRun(_crdtApi, _fwDataApi, projectSnapshot); var syncResult = await _syncService.Sync(_crdtApi, _fwDataApi, projectSnapshot); dryRunSyncResult.CrdtChanges.Should().Be(syncResult.CrdtChanges); - // Fwdata changes now match: the dry run applies the CRDT side to a throwaway copy, so direction B reads - // back the same state a real sync would (before, the empty CRDT made it predict deleting everything). + // Fwdata changes now match: the CRDT side ran against a throwaway copy, so direction B reads back the + // same state a real sync would (before, the empty CRDT made it predict deleting everything). dryRunSyncResult.FwdataChanges.Should().Be(syncResult.FwdataChanges); } diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index a6c4cb52c7..da2194b682 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -65,12 +65,10 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc // No write normalization: Data is already normalised on both sides. // No query normalization: The sync doesn't do any querying. - // A dry run must report exactly what a real sync would do, on both sides, without persisting. - // The catch: within one pass the sync writes the CRDT then reads it back (direction B reads the CRDT - // that direction A just wrote), which a record-only wrapper can't satisfy. So the CRDT side runs the - // real sync against a throwaway copy of its database — writes apply and read back faithfully. FwData is - // read once up front and never read back, and its file must not change, so it wraps a - // WriteIgnoringMiniLcmApi. RecordingMiniLcmApi records both sides. + // A dry run must predict a real sync without persisting. But the sync reads the CRDT back after writing + // it (direction B reads what direction A wrote), which a record-only wrapper can't fake. So the CRDT + // runs against a throwaway copy (writes apply and read back), while fwdata — read once, never read + // back, must not change — uses WriteIgnoringMiniLcmApi. RecordingMiniLcmApi records both. await using var crdtCopy = dryRun ? await crdtProjectsService.OpenTemporaryProjectCopy(crdt.Project) : null; if (dryRun) { diff --git a/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs b/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs index b8dd1c1e27..0f4d3425e6 100644 --- a/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs +++ b/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs @@ -1,4 +1,4 @@ namespace FwLiteProjectSync; -/// A human-readable record of a single write a dry-run sync would perform. +/// One write a dry run would make, as human-readable text. public record DryRunRecord(string Method, string Description); diff --git a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs index b9a9fc413d..0d9433562e 100644 --- a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs @@ -5,25 +5,23 @@ namespace FwLiteProjectSync; /// -/// Records each write (for the dry-run report) and forwards it to the wrapped api. Used on both sides of a -/// dry-run sync, differing only in what it wraps: the CRDT side wraps a throwaway copy of the project (see -/// ) so writes really apply and the sync can -/// read its own writes back faithfully; the fwdata side wraps a so the -/// recorded writes never touch the file. Record strings match what the pre-split dry-run api produced. +/// Records each write for the dry-run report, then forwards it. Both dry-run sides use it: the CRDT side wraps +/// a throwaway copy (see ) so writes apply +/// and read back; the fwdata side wraps a so they never touch the file. +/// Record strings match the pre-split dry-run api. /// public partial class RecordingMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi { private readonly IMiniLcmApi _api = api; - // Reads are forwarded by BeaKona and not recorded (only writes are). Writes are NOT auto-forwarded, so the - // compiler forces each one to be implemented here — and thus recorded — rather than silently passing through. + // BeaKona forwards reads (not recorded — only writes are). Writes aren't auto-forwarded, so the compiler + // forces each to be implemented, and thus recorded, here. [BeaKona.AutoInterface] private IMiniLcmReadApi ReadApi => _api; public List DryRunRecords { get; } = []; - // Don't dispose the wrapped api: it belongs to the caller (the CRDT copy is disposed by its - // TempCrdtProjectCopy handle; the fwdata api by whoever opened it). + // Wrapped api is the caller's to dispose (CRDT copy via TempCrdtProjectCopy; fwdata by its opener). public void Dispose() { } @@ -396,9 +394,8 @@ public Task RemovePublication(Guid entryId, Guid publicationId) } #region Submit (sync's result-less write variants) - // Submit* are writes, so (like every write) they're implemented here to record and forward. Any Submit* not - // listed falls back to the interface default, which routes to the returning Update*/Move* above — those - // record too, so it stays covered. + // 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 Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) { DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}")); diff --git a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs index 44efab683c..00077a69fe 100644 --- a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs @@ -6,13 +6,12 @@ namespace FwLiteProjectSync; /// -/// Reads pass through to the wrapped api; writes are ignored and return a plausible value (the input, or the -/// current state) instead of being applied. Used as the inner api for the fwdata side of a dry run, wrapped by -/// : the fwdata file must not change and is never read back mid-sync, so a -/// write that leaves reads untouched is exactly what the dry run wants. +/// Reads pass through; writes are ignored, returning a plausible value (the input or current state) instead +/// of applying. The fwdata side of a dry run wraps this (under ): its file +/// must not change and is never read back, so ignoring writes is exactly right. /// -// The api is typed IMiniLcmReadApi so this class can't forward a write even by accident. Reads are forwarded by -// BeaKona; every write is implemented below (the compiler enforces it, since IMiniLcmWriteApi isn't generated). +// api is IMiniLcmReadApi so a write can't be forwarded by accident. BeaKona forwards reads; every write is +// implemented below (compiler-enforced, since IMiniLcmWriteApi isn't generated). public partial class WriteIgnoringMiniLcmApi(IMiniLcmReadApi api) : IMiniLcmApi { [BeaKona.AutoInterface] @@ -52,7 +51,7 @@ public Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, Betwee public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) { - // Reads won't surface an ignored write, so return the input rather than re-reading. + // Ignored write isn't readable back, so return the input. return Task.FromResult(partOfSpeech); } @@ -129,6 +128,7 @@ public Task UpdateMorphType(MorphType before, MorphType after, IMiniL 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); else @@ -335,9 +335,8 @@ public Task RemovePublication(Guid entryId, Guid publicationId) } #region Submit (sync's result-less write variants) - // Implemented explicitly rather than falling back to the interface default, which routes to the returning - // Update* and re-reads the object — that would throw when a dry run of a conflicted project hits an object - // the other side has already deleted. + // 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; diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 4c0efdd113..920436dda0 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -285,11 +285,9 @@ public virtual async Task CreateProject(CreateProjectRequest reques } /// - /// Opens a throwaway copy of an existing project's database in its own service scope. The copy lives in - /// a temp directory (not ), so it never shows up in . - /// Used by dry-run sync: changes can be really applied to (and read back from) the copy without touching - /// the original. The returned is disposable — dispose it (e.g. with - /// await using) to close the copy and delete its temp files. + /// Opens a throwaway copy of a project's database in its own scope and a temp dir (so it stays out of + /// ). Lets a dry run apply and read back changes without touching the original; + /// dispose the returned to close it and delete the temp files. /// public async Task OpenTemporaryProjectCopy(CrdtProject source) { @@ -302,8 +300,7 @@ public async Task OpenTemporaryProjectCopy(CrdtProject sour { await sourceConnection.OpenAsync(); await copyConnection.OpenAsync(); - // Online backup API, not File.Copy: captures a consistent committed snapshot while the source is - // open, including any WAL sidecar files a plain file copy could miss. + // Online backup API, not File.Copy: a consistent snapshot of the open db, WAL included. sourceConnection.BackupDatabase(copyConnection); } diff --git a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs index d57f99b2af..cc6132fc94 100644 --- a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs +++ b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs @@ -3,18 +3,17 @@ namespace LcmCrdt; /// -/// A disposable, throwaway copy of a CRDT project's database opened in its own service scope -/// (see ). Used by dry-run sync so changes can be -/// really applied and read back without touching the original project. Disposing closes the copy's -/// scope and deletes its temporary database files. +/// A throwaway copy of a CRDT project's db in its own scope (see +/// ), so a dry run can apply and read back changes +/// without touching the original. Disposing closes the scope and deletes the temp files. /// -public sealed class TempCrdtProjectCopy(IMiniLcmApi api, AsyncServiceScope scope, Func deleteFiles) : IAsyncDisposable +public sealed class TempCrdtProjectCopy(IMiniLcmApi api, AsyncServiceScope scope, Func cleanup) : IAsyncDisposable { public IMiniLcmApi Api { get; } = api; public async ValueTask DisposeAsync() { await scope.DisposeAsync(); - await deleteFiles(); + await cleanup(); } } From 0bfadf24b17ca4bd64756cb1728d1f4d2cb76722 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 18:58:35 +0200 Subject: [PATCH 07/12] Cleanup --- .../FwLiteProjectSync.Tests/Sena3SyncTests.cs | 66 ++++---- .../CrdtFwdataProjectSyncService.cs | 43 +++--- .../FwLite/FwLiteProjectSync/DryRunRecord.cs | 4 - .../FwLiteProjectSync/RecordingMiniLcmApi.cs | 146 +++++++++--------- .../WriteIgnoringMiniLcmApi.cs | 7 - backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 5 +- backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs | 4 +- 7 files changed, 127 insertions(+), 148 deletions(-) delete mode 100644 backend/FwLite/FwLiteProjectSync/DryRunRecord.cs diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 451adc215c..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,15 +120,19 @@ public async Task DryRunImport_MakesTheSameChangesAsImport() [Trait("Category", "Integration")] public async Task DryRunSync_MakesNoChanges() { - // The dry run applies the WS sync to the CRDT copy, so snapshot WSs must match the CRDT's or it throws - // re-creating existing ones (a real sync would too). Import them so both sides agree. - await _project.Services.GetRequiredService() - .ImportWritingSystems(_crdtApi, await _fwDataApi.GetWritingSystems()); - var projectSnapshot = await CreateAndSaveMinimalSnapshot(withWritingSystems: true); - _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); - // The dry run applies the CRDT side to a throwaway copy, so the real project is untouched. - _crdtApi.GetEntries().ToBlockingEnumerable().Should().BeEmpty(); + + var crdtSnapshotAfter = await _crdtApi.TakeProjectSnapshot(); + var fwdataSnapshotAfter = await _fwDataApi.TakeProjectSnapshot(); + + crdtSnapshotAfter.Should().BeEquivalentTo(crdtSnapshotBefore); + fwdataSnapshotAfter.Should().BeEquivalentTo(fwdataSnapshotBefore); } [Fact] @@ -136,15 +140,12 @@ await _project.Services.GetRequiredService() [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); - // Fwdata changes now match: the CRDT side ran against a throwaway copy, so direction B reads back the - // same state a real sync would (before, the empty CRDT made it predict deleting everything). + // 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); } @@ -176,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 da2194b682..86c964afdf 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -17,8 +17,8 @@ public class CrdtFwdataProjectSyncService(MiniLcmImport miniLcmImport, 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) { @@ -62,24 +62,21 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc throw new InvalidOperationException("Project sync state does not match presence of snapshot."); } - // No write normalization: Data is already normalised on both sides. - // No query normalization: The sync doesn't do any querying. - - // A dry run must predict a real sync without persisting. But the sync reads the CRDT back after writing - // it (direction B reads what direction A wrote), which a record-only wrapper can't fake. So the CRDT - // runs against a throwaway copy (writes apply and read back), while fwdata — read once, never read - // back, must not change — uses WriteIgnoringMiniLcmApi. RecordingMiniLcmApi records both. await using var crdtCopy = dryRun ? await crdtProjectsService.OpenTemporaryProjectCopy(crdt.Project) : null; if (dryRun) { - crdt = (CrdtMiniLcmApi)crdtCopy!.Api; - crdtApi = new RecordingMiniLcmApi(validationWrapperFactory.Create(crdt)); - fwdataApi = new RecordingMiniLcmApi(new WriteIgnoringMiniLcmApi(validationWrapperFactory.Create(fwdataApi))); + crdtApi = crdtCopy?.Api ?? throw new InvalidOperationException("crdtCopy must be defined in a dryRun"); } - else + + // No write normalization: Data is already normalised on both sides. + // No query normalization: The sync doesn't do any querying. + crdtApi = validationWrapperFactory.Create(crdtApi); + fwdataApi = validationWrapperFactory.Create(fwdataApi); + + if (dryRun) { - crdtApi = validationWrapperFactory.Create(crdtApi); - fwdataApi = validationWrapperFactory.Create(fwdataApi); + crdtApi = new RecordingMiniLcmApi(crdtApi); + fwdataApi = new RecordingMiniLcmApi(new WriteIgnoringMiniLcmApi(fwdataApi)); } if (projectSnapshot is not null) @@ -110,10 +107,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) @@ -155,19 +152,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 RecordingMiniLcmApi recorder) return; - foreach (var dryRunRecord in recorder.DryRunRecords) + foreach (var dryRunRecord in recorder.RunRecords) { logger.LogInformation($"Dry run {type} record: {dryRunRecord.Method} {dryRunRecord.Description}"); } - logger.LogInformation($"Dry run {type} changes: {recorder.DryRunRecords.Count}"); + logger.LogInformation($"Dry run {type} changes: {recorder.RunRecords.Count}"); } - private List GetDryRunRecords(IMiniLcmApi api) + private List GetRunRecords(IMiniLcmApi api) { - return ((RecordingMiniLcmApi)api).DryRunRecords; + return ((RecordingMiniLcmApi)api).RunRecords; } } diff --git a/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs b/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs deleted file mode 100644 index 0f4d3425e6..0000000000 --- a/backend/FwLite/FwLiteProjectSync/DryRunRecord.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace FwLiteProjectSync; - -/// One write a dry run would make, as human-readable text. -public record DryRunRecord(string Method, string Description); diff --git a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs index 0d9433562e..bf90d99a17 100644 --- a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs @@ -4,31 +4,27 @@ namespace FwLiteProjectSync; -/// -/// Records each write for the dry-run report, then forwards it. Both dry-run sides use it: the CRDT side wraps -/// a throwaway copy (see ) so writes apply -/// and read back; the fwdata side wraps a so they never touch the file. -/// Record strings match the pre-split dry-run api. -/// public partial class RecordingMiniLcmApi(IMiniLcmApi api) : IMiniLcmApi { + + public record RunRecord(string Method, string Description); + private readonly IMiniLcmApi _api = api; - // BeaKona forwards reads (not recorded — only writes are). Writes aren't auto-forwarded, so the compiler - // forces each to be implemented, and thus recorded, here. + // Writes aren't auto-forwarded, so the compiler forces each to be recorded [BeaKona.AutoInterface] private IMiniLcmReadApi ReadApi => _api; - public List DryRunRecords { get; } = []; + public List RunRecords { get; } = []; - // Wrapped api is the caller's to dispose (CRDT copy via TempCrdtProjectCopy; fwdata by its opener). + // Wrapped api is the caller's to dispose public void Dispose() { } public Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? position = null) { - DryRunRecords.Add(new DryRunRecord(nameof(CreateWritingSystem), + RunRecords.Add(new RunRecord(nameof(CreateWritingSystem), $"Create writing system {writingSystem.Type} between {position?.Previous} and {position?.Next}")); return _api.CreateWritingSystem(writingSystem, position); } @@ -37,198 +33,198 @@ public Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateWritingSystem), + RunRecords.Add(new RunRecord(nameof(UpdateWritingSystem), $"Update writing system {type}, changes: {update.Summarize()}")); return _api.UpdateWritingSystem(id, type, update); } public Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateWritingSystem), $"Update {after.Type} writing system {after.WsId}")); + RunRecords.Add(new RunRecord(nameof(UpdateWritingSystem), $"Update {after.Type} writing system {after.WsId}")); return _api.UpdateWritingSystem(before, after, api); } public Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) { - DryRunRecords.Add(new DryRunRecord(nameof(MoveWritingSystem), $"Move writing system {id} between {between.Previous} and {between.Next}")); + RunRecords.Add(new RunRecord(nameof(MoveWritingSystem), $"Move writing system {id} between {between.Previous} and {between.Next}")); return _api.MoveWritingSystem(id, type, between); } public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) { - DryRunRecords.Add(new DryRunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); + RunRecords.Add(new RunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); return _api.CreatePartOfSpeech(partOfSpeech); } public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}")); + RunRecords.Add(new RunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}")); return _api.UpdatePartOfSpeech(id, update); } public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}")); + RunRecords.Add(new RunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}")); return _api.UpdatePartOfSpeech(before, after, api); } public Task DeletePartOfSpeech(Guid id) { - DryRunRecords.Add(new DryRunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}")); + RunRecords.Add(new RunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}")); return _api.DeletePartOfSpeech(id); } public Task CreateSemanticDomain(SemanticDomain semanticDomain) { - DryRunRecords.Add(new DryRunRecord(nameof(CreateSemanticDomain), + RunRecords.Add(new RunRecord(nameof(CreateSemanticDomain), $"Create semantic domain {semanticDomain.Name}")); return _api.CreateSemanticDomain(semanticDomain); } public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {id}")); + RunRecords.Add(new RunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {id}")); return _api.UpdateSemanticDomain(id, update); } public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}")); + RunRecords.Add(new RunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}")); return _api.UpdateSemanticDomain(before, after, api); } public Task DeleteSemanticDomain(Guid id) { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}")); + RunRecords.Add(new RunRecord(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}")); return _api.DeleteSemanticDomain(id); } public Task CreateComplexFormType(ComplexFormType complexFormType) { - DryRunRecords.Add(new DryRunRecord(nameof(CreateComplexFormType), + RunRecords.Add(new RunRecord(nameof(CreateComplexFormType), $"Create complex form type {complexFormType.Name}")); return _api.CreateComplexFormType(complexFormType); } public Task UpdateComplexFormType(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateComplexFormType), $"Update complex form type {id}")); + RunRecords.Add(new RunRecord(nameof(UpdateComplexFormType), $"Update complex form type {id}")); return _api.UpdateComplexFormType(id, update); } public Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateComplexFormType), $"Update complex form type {after.Id}")); + RunRecords.Add(new RunRecord(nameof(UpdateComplexFormType), $"Update complex form type {after.Id}")); return _api.UpdateComplexFormType(before, after, api); } public Task DeleteComplexFormType(Guid id) { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormType), $"Delete complex form type {id}")); + RunRecords.Add(new RunRecord(nameof(DeleteComplexFormType), $"Delete complex form type {id}")); return _api.DeleteComplexFormType(id); } public Task CreateMorphType(MorphType morphType) { - DryRunRecords.Add(new DryRunRecord(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})")); + RunRecords.Add(new RunRecord(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})")); return _api.CreateMorphType(morphType); } public Task UpdateMorphType(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateMorphType), $"Update morph type {id}")); + RunRecords.Add(new RunRecord(nameof(UpdateMorphType), $"Update morph type {id}")); return _api.UpdateMorphType(id, update); } public Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateMorphType), $"Update morph type {after.Id}")); + RunRecords.Add(new RunRecord(nameof(UpdateMorphType), $"Update morph type {after.Id}")); return _api.UpdateMorphType(before, after, api); } public Task CreateEntry(Entry entry, CreateEntryOptions? options = null) { - DryRunRecords.Add(new DryRunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options ?? new CreateEntryOptions()})")); + RunRecords.Add(new RunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options ?? new CreateEntryOptions()})")); return _api.CreateEntry(entry, options); } public Task UpdateEntry(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateEntry), $"Update entry {id}")); + RunRecords.Add(new RunRecord(nameof(UpdateEntry), $"Update entry {id}")); return _api.UpdateEntry(id, update); } public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateEntry), $"Update entry {after.Id}")); + RunRecords.Add(new RunRecord(nameof(UpdateEntry), $"Update entry {after.Id}")); return _api.UpdateEntry(before, after, api); } public Task DeleteEntry(Guid id) { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteEntry), $"Delete entry {id}")); + RunRecords.Add(new RunRecord(nameof(DeleteEntry), $"Delete entry {id}")); return _api.DeleteEntry(id); } public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) { - DryRunRecords.Add(new DryRunRecord(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId}, from entry {entryId}")); + RunRecords.Add(new RunRecord(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId}, from entry {entryId}")); return _api.RemoveComplexFormType(entryId, complexFormTypeId); } 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}")); + RunRecords.Add(new RunRecord(nameof(CreateSense), $"Create sense {sense.Gloss} between {position?.Previous} and {position?.Next}")); return _api.CreateSense(entryId, sense, position); } public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSense), + RunRecords.Add(new RunRecord(nameof(UpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}")); return _api.UpdateSense(entryId, senseId, update); } public Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateSense), + RunRecords.Add(new RunRecord(nameof(UpdateSense), $"Update sense {after.Id}")); return _api.UpdateSense(entryId, before, after, api); } public Task MoveSense(Guid entryId, Guid senseId, BetweenPosition between) { - DryRunRecords.Add(new DryRunRecord(nameof(MoveSense), $"Move sense {senseId} between {between.Previous} and {between.Next}")); + RunRecords.Add(new RunRecord(nameof(MoveSense), $"Move sense {senseId} between {between.Previous} and {between.Next}")); return _api.MoveSense(entryId, senseId, between); } public Task DeleteSense(Guid entryId, Guid senseId) { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteSense), $"Delete sense {senseId}")); + RunRecords.Add(new RunRecord(nameof(DeleteSense), $"Delete sense {senseId}")); return _api.DeleteSense(entryId, senseId); } public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) { - DryRunRecords.Add(new DryRunRecord(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Name}")); + RunRecords.Add(new RunRecord(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Name}")); return _api.AddSemanticDomainToSense(senseId, semanticDomain); } public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) { - DryRunRecords.Add(new DryRunRecord(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId}")); + RunRecords.Add(new RunRecord(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId}")); return _api.RemoveSemanticDomainFromSense(senseId, semanticDomainId); } public Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) { - DryRunRecords.Add(new DryRunRecord(nameof(SetSensePartOfSpeech), $"Set part of speech {partOfSpeechId}")); + RunRecords.Add(new RunRecord(nameof(SetSensePartOfSpeech), $"Set part of speech {partOfSpeechId}")); return _api.SetSensePartOfSpeech(senseId, partOfSpeechId); } 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}")); + RunRecords.Add(new RunRecord(nameof(CreateExampleSentence), $"Create example sentence {exampleSentence.Sentence} between {position?.Previous} and {position?.Next}")); return _api.CreateExampleSentence(entryId, senseId, exampleSentence, position); } @@ -237,7 +233,7 @@ public Task UpdateExampleSentence(Guid entryId, Guid exampleSentenceId, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence), + RunRecords.Add(new RunRecord(nameof(UpdateExampleSentence), $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); return _api.UpdateExampleSentence(entryId, senseId, exampleSentenceId, update); } @@ -248,31 +244,31 @@ public Task UpdateExampleSentence(Guid entryId, ExampleSentence after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateExampleSentence), $"Update example sentence {after.Id}")); + RunRecords.Add(new RunRecord(nameof(UpdateExampleSentence), $"Update example sentence {after.Id}")); return _api.UpdateExampleSentence(entryId, senseId, before, after, api); } 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}")); + RunRecords.Add(new RunRecord(nameof(MoveExampleSentence), $"Move example sentence {exampleId} between {between.Previous} and {between.Next}")); return _api.MoveExampleSentence(entryId, senseId, exampleId, between); } public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) { - DryRunRecords.Add(new DryRunRecord(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}")); + RunRecords.Add(new RunRecord(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}")); return _api.DeleteExampleSentence(entryId, senseId, exampleSentenceId); } 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}")); + RunRecords.Add(new RunRecord(nameof(AddTranslation), $"Add translation {translation.Id} to example sentence {exampleSentenceId}")); return _api.AddTranslation(entryId, senseId, exampleSentenceId, translation); } public Task RemoveTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId) { - DryRunRecords.Add(new DryRunRecord(nameof(RemoveTranslation), $"Remove translation {translationId} from example sentence {exampleSentenceId}")); + RunRecords.Add(new RunRecord(nameof(RemoveTranslation), $"Remove translation {translationId} from example sentence {exampleSentenceId}")); return _api.RemoveTranslation(entryId, senseId, exampleSentenceId, translationId); } @@ -282,14 +278,14 @@ public Task UpdateTranslation(Guid entryId, Guid translationId, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdateTranslation), $"Update translation {translationId} in example sentence {exampleSentenceId}")); + RunRecords.Add(new RunRecord(nameof(UpdateTranslation), $"Update translation {translationId} in example sentence {exampleSentenceId}")); return _api.UpdateTranslation(entryId, senseId, exampleSentenceId, translationId, update); } 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}")); + RunRecords.Add(new RunRecord(nameof(CreatePicture), $"Create picture {picture.Caption} between {position?.Previous} and {position?.Next}")); return _api.CreatePicture(entryId, senseId, picture, position); } @@ -298,7 +294,7 @@ public Task UpdatePicture(Guid entryId, Guid pictureId, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePicture), + RunRecords.Add(new RunRecord(nameof(UpdatePicture), $"Update picture {pictureId}, changes: {update.Summarize()}")); return _api.UpdatePicture(entryId, senseId, pictureId, update); } @@ -309,19 +305,19 @@ public Task UpdatePicture(Guid entryId, Picture after, IMiniLcmApi? api) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePicture), $"Update picture {after.Id}")); + RunRecords.Add(new RunRecord(nameof(UpdatePicture), $"Update picture {after.Id}")); return _api.UpdatePicture(entryId, senseId, before, after, api); } 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}")); + RunRecords.Add(new RunRecord(nameof(MovePicture), $"Move picture {exampleId} between {between.Previous} and {between.Next}")); return _api.MovePicture(entryId, senseId, exampleId, between); } public Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) { - DryRunRecords.Add(new DryRunRecord(nameof(DeletePicture), $"Delete picture {pictureId}")); + RunRecords.Add(new RunRecord(nameof(DeletePicture), $"Delete picture {pictureId}")); return _api.DeletePicture(entryId, senseId, pictureId); } @@ -331,7 +327,7 @@ public Task CreateComplexFormComponent(ComplexFormComponen 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}")); + RunRecords.Add(new RunRecord(nameof(CreateComplexFormComponent), $"Create complex form component complex entry: {complexFormName}, component entry: {componentName}, between {previous} and {next}")); return _api.CreateComplexFormComponent(complexFormComponent, between); } @@ -340,56 +336,56 @@ public Task MoveComplexFormComponent(ComplexFormComponent complexFormComponent, 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}")); + RunRecords.Add(new RunRecord(nameof(MoveComplexFormComponent), $"Move complex form component {componentName} between {previous} and {next}")); return _api.MoveComplexFormComponent(complexFormComponent, between); } public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) { var componentName = ComplexFormComponentName(complexFormComponent); - DryRunRecords.Add(new DryRunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component: {componentName}")); + RunRecords.Add(new RunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component: {componentName}")); return _api.DeleteComplexFormComponent(complexFormComponent); } public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) { - DryRunRecords.Add(new DryRunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); + RunRecords.Add(new RunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); return _api.AddComplexFormType(entryId, complexFormTypeId); } public Task CreatePublication(Publication pub) { - DryRunRecords.Add(new DryRunRecord(nameof(CreatePublication), $"Create publication {pub.Id}")); + RunRecords.Add(new RunRecord(nameof(CreatePublication), $"Create publication {pub.Id}")); return _api.CreatePublication(pub); } public Task UpdatePublication(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePublication), $"Update publication {id}")); + RunRecords.Add(new RunRecord(nameof(UpdatePublication), $"Update publication {id}")); return _api.UpdatePublication(id, update); } public Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) { - DryRunRecords.Add(new DryRunRecord(nameof(UpdatePublication), $"Update publication {before.Id}")); + RunRecords.Add(new RunRecord(nameof(UpdatePublication), $"Update publication {before.Id}")); return _api.UpdatePublication(before, after, api); } public Task DeletePublication(Guid id) { - DryRunRecords.Add(new DryRunRecord(nameof(DeletePublication), $"Delete publication {id}")); + RunRecords.Add(new RunRecord(nameof(DeletePublication), $"Delete publication {id}")); return _api.DeletePublication(id); } public Task AddPublication(Guid entryId, Guid publicationId) { - DryRunRecords.Add(new DryRunRecord(nameof(AddPublication), $"Add publication {publicationId} to entry {entryId}")); + RunRecords.Add(new RunRecord(nameof(AddPublication), $"Add publication {publicationId} to entry {entryId}")); return _api.AddPublication(entryId, publicationId); } public Task RemovePublication(Guid entryId, Guid publicationId) { - DryRunRecords.Add(new DryRunRecord(nameof(RemovePublication), $"Remove publication {publicationId} from entry {entryId}")); + RunRecords.Add(new RunRecord(nameof(RemovePublication), $"Remove publication {publicationId} from entry {entryId}")); return _api.RemovePublication(entryId, publicationId); } @@ -398,61 +394,61 @@ public Task RemovePublication(Guid entryId, Guid publicationId) // interface default, which routes to the recording Update*/Move* above. public Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}")); + RunRecords.Add(new RunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}")); return _api.SubmitUpdateEntry(id, update); } public Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}")); + RunRecords.Add(new RunRecord(nameof(SubmitUpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}")); return _api.SubmitUpdateSense(entryId, senseId, update); } public Task SubmitUpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateExampleSentence), $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); + RunRecords.Add(new RunRecord(nameof(SubmitUpdateExampleSentence), $"Update example sentence {exampleSentenceId}, changes: {update.Summarize()}")); return _api.SubmitUpdateExampleSentence(entryId, senseId, exampleSentenceId, update); } public Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateSense), $"Create sense {sense.Gloss}")); + RunRecords.Add(new RunRecord(nameof(SubmitCreateSense), $"Create sense {sense.Gloss}")); return _api.SubmitCreateSense(entryId, sense, position); } public Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); + RunRecords.Add(new RunRecord(nameof(SubmitCreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); return _api.SubmitCreateExampleSentence(entryId, senseId, exampleSentence, position); } public Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitCreateComplexFormComponent), $"Create complex form component {ComplexFormComponentName(complexFormComponent)}")); + RunRecords.Add(new RunRecord(nameof(SubmitCreateComplexFormComponent), $"Create complex form component {ComplexFormComponentName(complexFormComponent)}")); return _api.SubmitCreateComplexFormComponent(complexFormComponent, position); } public Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePartOfSpeech), $"Update part of speech {id}")); + RunRecords.Add(new RunRecord(nameof(SubmitUpdatePartOfSpeech), $"Update part of speech {id}")); return _api.SubmitUpdatePartOfSpeech(id, update); } public Task SubmitUpdatePublication(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdatePublication), $"Update publication {id}")); + RunRecords.Add(new RunRecord(nameof(SubmitUpdatePublication), $"Update publication {id}")); return _api.SubmitUpdatePublication(id, update); } public Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateSemanticDomain), $"Update semantic domain {id}")); + RunRecords.Add(new RunRecord(nameof(SubmitUpdateSemanticDomain), $"Update semantic domain {id}")); return _api.SubmitUpdateSemanticDomain(id, update); } public Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput update) { - DryRunRecords.Add(new DryRunRecord(nameof(SubmitUpdateComplexFormType), $"Update complex form type {id}")); + RunRecords.Add(new RunRecord(nameof(SubmitUpdateComplexFormType), $"Update complex form type {id}")); return _api.SubmitUpdateComplexFormType(id, update); } #endregion diff --git a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs index 00077a69fe..2167d90ee5 100644 --- a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs @@ -5,13 +5,6 @@ namespace FwLiteProjectSync; -/// -/// Reads pass through; writes are ignored, returning a plausible value (the input or current state) instead -/// of applying. The fwdata side of a dry run wraps this (under ): its file -/// must not change and is never read back, so ignoring writes is exactly right. -/// -// api is IMiniLcmReadApi so a write can't be forwarded by accident. BeaKona forwards reads; every write is -// implemented below (compiler-enforced, since IMiniLcmWriteApi isn't generated). public partial class WriteIgnoringMiniLcmApi(IMiniLcmReadApi api) : IMiniLcmApi { [BeaKona.AutoInterface] diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 920436dda0..586be7107d 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -285,9 +285,7 @@ public virtual async Task CreateProject(CreateProjectRequest reques } /// - /// Opens a throwaway copy of a project's database in its own scope and a temp dir (so it stays out of - /// ). Lets a dry run apply and read back changes without touching the original; - /// dispose the returned to close it and delete the temp files. + /// Opens a throwaway copy of a project's database in its own scope and a temp dir /// public async Task OpenTemporaryProjectCopy(CrdtProject source) { @@ -300,7 +298,6 @@ public async Task OpenTemporaryProjectCopy(CrdtProject sour { await sourceConnection.OpenAsync(); await copyConnection.OpenAsync(); - // Online backup API, not File.Copy: a consistent snapshot of the open db, WAL included. sourceConnection.BackupDatabase(copyConnection); } diff --git a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs index cc6132fc94..9e62644ed6 100644 --- a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs +++ b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs @@ -3,9 +3,7 @@ namespace LcmCrdt; /// -/// A throwaway copy of a CRDT project's db in its own scope (see -/// ), so a dry run can apply and read back changes -/// without touching the original. Disposing closes the scope and deletes the temp files. +/// A throwaway, disposable copy of a CRDT project's db /// public sealed class TempCrdtProjectCopy(IMiniLcmApi api, AsyncServiceScope scope, Func cleanup) : IAsyncDisposable { From 81e49241490455ef0e4d9160a9f9d37486158ce0 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 19:23:42 +0200 Subject: [PATCH 08/12] Fix dry run writing translation-id repair to the real CRDT project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dry-run rebinding only swapped crdtApi to the throwaway copy; crdt still pointed at the real project. CrdtRepairs.SyncMissingTranslationIds (which lost its dryRun guard) is passed crdt, so a dry run on a project with a missing translation ID committed SetFirstTranslationIds to the live CRDT — and the copy, snapshotted first, never got the repair, so the prediction diverged from a real sync too. Rebind crdt to the copy in dry run so the repair (and the morph-type read) hit the copy. Regression test: CrdtEntryMissingTranslationId_DryRunSync_LeavesRealCrdtUntouched (fails without this fix). Also from the review: - OpenTemporaryProjectCopy now disposes the scope and deletes the temp file if it throws before handing them to TempCrdtProjectCopy. - TempCrdtProjectCopy.DisposeAsync uses try/finally so cleanup runs even if scope disposal throws. - Update three AGENTS/agent docs that still named the deleted DryRunMiniLcmApi. Co-Authored-By: Claude Opus 4.8 --- .claude/agents/test-auditor.md | 2 +- backend/FwHeadless/AGENTS.md | 2 +- backend/FwLite/AGENTS.md | 2 +- .../CrdtRepairTests.cs | 19 ++++++++++++ .../CrdtFwdataProjectSyncService.cs | 6 +++- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 29 +++++++++++++------ backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs | 11 +++++-- 7 files changed, 56 insertions(+), 15 deletions(-) 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/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/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index 86c964afdf..04ad000455 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -65,7 +65,11 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc await using var crdtCopy = dryRun ? await crdtProjectsService.OpenTemporaryProjectCopy(crdt.Project) : null; if (dryRun) { - crdtApi = crdtCopy?.Api ?? throw new InvalidOperationException("crdtCopy must be defined in a 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 as CrdtMiniLcmApi + ?? throw new InvalidOperationException("dry run requires a CrdtMiniLcmApi copy"); + crdtApi = crdt; } // No write normalization: Data is already normalised on both sides. diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index 586be7107d..cb23e78a69 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -293,17 +293,28 @@ public async Task OpenTemporaryProjectCopy(CrdtProject sour Directory.CreateDirectory(tempDir); var tempPath = Path.Combine(tempDir, $"{source.Name}-{Guid.NewGuid():N}.sqlite"); - await using (var sourceConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = source.DbPath }.ConnectionString)) - await using (var copyConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = tempPath }.ConnectionString)) + // Nothing owns the temp file or scope until TempCrdtProjectCopy is returned, so undo both if we throw first. + AsyncServiceScope? scope = null; + try { - await sourceConnection.OpenAsync(); - await copyConnection.OpenAsync(); - sourceConnection.BackupDatabase(copyConnection); - } + 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); + } - var scope = provider.CreateAsyncScope(); - var api = await scope.ServiceProvider.OpenCrdtProject(new CrdtProject(source.Name, tempPath)); - return new TempCrdtProjectCopy(api, scope, () => EnsureDeleteProject(tempPath, suppressException: true)); + scope = provider.CreateAsyncScope(); + var api = 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) diff --git a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs index 9e62644ed6..c8538fdfe3 100644 --- a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs +++ b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs @@ -11,7 +11,14 @@ public sealed class TempCrdtProjectCopy(IMiniLcmApi api, AsyncServiceScope scope public async ValueTask DisposeAsync() { - await scope.DisposeAsync(); - await cleanup(); + // finally so the temp files are deleted even if scope disposal throws. + try + { + await scope.DisposeAsync(); + } + finally + { + await cleanup(); + } } } From 2d584730167fdf42f44ba99e0378dad9087d500c Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 19:46:49 +0200 Subject: [PATCH 09/12] Fix FwHeadless SyncWorker tests for the new sync-service constructor arg, and two DeepSource nits The branch added a CrdtProjectsService parameter to CrdtFwdataProjectSyncService, but SyncWorkerTestHarness builds a Mock with the old 3-arg constructor, so Moq couldn't find a matching constructor and every SyncWorkerTests case threw. Pass the extra null! arg. Also from DeepSource: - Drop the unused syncedIdCount local (the repair's return value was never read). - Remove a redundant else-after-return in WriteIgnoringMiniLcmApi.CreateEntry. Co-Authored-By: Claude Opus 4.8 --- .../FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs | 2 +- backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs | 3 +-- backend/Testing/FwHeadless/Services/SyncWorkerTestHarness.cs | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index 04ad000455..fe9513f554 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -86,7 +86,7 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc 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); + 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 []. diff --git a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs index 2167d90ee5..c4ee3f4f3d 100644 --- a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs @@ -124,8 +124,7 @@ public Task CreateEntry(Entry entry, CreateEntryOptions? options) // Return only what a real create would persist; without this option those get dropped. if (options.IncludeComplexFormsAndComponents) return Task.FromResult(entry); - else - return Task.FromResult(entry with { Components = [], ComplexForms = [] }); + return Task.FromResult(entry with { Components = [], ComplexForms = [] }); } public Task UpdateEntry(Guid id, UpdateObjectInput update) 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 From b12584eb8cc2e14937e745473d40bfb74248d2ed Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 20:53:42 +0200 Subject: [PATCH 10/12] Type the temp CRDT copy as CrdtMiniLcmApi so callers don't cast OpenCrdtProject is typed to IMiniLcmApi, but a CRDT project always resolves a CrdtMiniLcmApi (registered directly, no decorators). A temp copy is always a CRDT project, so cast once at that boundary and expose TempCrdtProjectCopy.Api as CrdtMiniLcmApi. The dry-run sync call site no longer casts. Co-Authored-By: Claude Opus 4.8 --- .../FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs | 3 +-- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 3 ++- backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index fe9513f554..b6e10a64cd 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -67,8 +67,7 @@ private async Task SyncOrImportInternal(IMiniLcmApi crdtApi, IMiniLc { // 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 as CrdtMiniLcmApi - ?? throw new InvalidOperationException("dry run requires a CrdtMiniLcmApi copy"); + crdt = crdtCopy?.Api ?? throw new InvalidOperationException("crdtCopy must be defined in a dryRun"); crdtApi = crdt; } diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index cb23e78a69..a235f36cfd 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -306,7 +306,8 @@ public async Task OpenTemporaryProjectCopy(CrdtProject sour } scope = provider.CreateAsyncScope(); - var api = await scope.Value.ServiceProvider.OpenCrdtProject(new CrdtProject(source.Name, tempPath)); + // 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 diff --git a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs index c8538fdfe3..9436c95710 100644 --- a/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs +++ b/backend/FwLite/LcmCrdt/TempCrdtProjectCopy.cs @@ -5,9 +5,9 @@ namespace LcmCrdt; /// /// A throwaway, disposable copy of a CRDT project's db /// -public sealed class TempCrdtProjectCopy(IMiniLcmApi api, AsyncServiceScope scope, Func cleanup) : IAsyncDisposable +public sealed class TempCrdtProjectCopy(CrdtMiniLcmApi api, AsyncServiceScope scope, Func cleanup) : IAsyncDisposable { - public IMiniLcmApi Api { get; } = api; + public CrdtMiniLcmApi Api { get; } = api; public async ValueTask DisposeAsync() { From a8824777e3c13460e4b04edd555be138615a0984 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Mon, 27 Jul 2026 20:54:05 +0200 Subject: [PATCH 11/12] Await before returning in the dry-run wrappers; document the convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecordingMiniLcmApi and WriteIgnoringMiniLcmApi forwarded tasks directly (return _api.X()). Awaiting keeps the wrapper frame on the stack trace when the inner call throws — worth it here since these are dry-run-only diagnostic aids, not a hot path. Task forwarders become `return await`, non-generic Task ones `await`. Record the team convention in backend/AGENTS.md, citing Kevin's rationale on #2435. Co-Authored-By: Claude Opus 4.8 --- backend/AGENTS.md | 2 +- .../FwLiteProjectSync/RecordingMiniLcmApi.cs | 260 +++++++++--------- .../WriteIgnoringMiniLcmApi.cs | 12 +- 3 files changed, 137 insertions(+), 137 deletions(-) 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/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs index bf90d99a17..522b57fe87 100644 --- a/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/RecordingMiniLcmApi.cs @@ -22,434 +22,434 @@ public void Dispose() { } - public Task CreateWritingSystem(WritingSystem writingSystem, BetweenPosition? position = null) + 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 _api.CreateWritingSystem(writingSystem, position); + return await _api.CreateWritingSystem(writingSystem, position); } - public Task UpdateWritingSystem(WritingSystemId id, + public async Task UpdateWritingSystem(WritingSystemId id, WritingSystemType type, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(UpdateWritingSystem), $"Update writing system {type}, changes: {update.Summarize()}")); - return _api.UpdateWritingSystem(id, type, update); + return await _api.UpdateWritingSystem(id, type, update); } - public Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api) + public async Task UpdateWritingSystem(WritingSystem before, WritingSystem after, IMiniLcmApi? api) { RunRecords.Add(new RunRecord(nameof(UpdateWritingSystem), $"Update {after.Type} writing system {after.WsId}")); - return _api.UpdateWritingSystem(before, after, api); + return await _api.UpdateWritingSystem(before, after, api); } - public Task MoveWritingSystem(WritingSystemId id, WritingSystemType type, BetweenPosition between) + 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}")); - return _api.MoveWritingSystem(id, type, between); + await _api.MoveWritingSystem(id, type, between); } - public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) + public async Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) { RunRecords.Add(new RunRecord(nameof(CreatePartOfSpeech), $"Create part of speech {partOfSpeech.Name}")); - return _api.CreatePartOfSpeech(partOfSpeech); + return await _api.CreatePartOfSpeech(partOfSpeech); } - public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) + public async Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {id}")); - return _api.UpdatePartOfSpeech(id, update); + return await _api.UpdatePartOfSpeech(id, update); } - public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) + public async Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) { RunRecords.Add(new RunRecord(nameof(UpdatePartOfSpeech), $"Update part of speech {after.Id}")); - return _api.UpdatePartOfSpeech(before, after, api); + return await _api.UpdatePartOfSpeech(before, after, api); } - public Task DeletePartOfSpeech(Guid id) + public async Task DeletePartOfSpeech(Guid id) { RunRecords.Add(new RunRecord(nameof(DeletePartOfSpeech), $"Delete part of speech {id}")); - return _api.DeletePartOfSpeech(id); + await _api.DeletePartOfSpeech(id); } - public Task CreateSemanticDomain(SemanticDomain semanticDomain) + public async Task CreateSemanticDomain(SemanticDomain semanticDomain) { RunRecords.Add(new RunRecord(nameof(CreateSemanticDomain), $"Create semantic domain {semanticDomain.Name}")); - return _api.CreateSemanticDomain(semanticDomain); + return await _api.CreateSemanticDomain(semanticDomain); } - public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) + public async Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {id}")); - return _api.UpdateSemanticDomain(id, update); + return await _api.UpdateSemanticDomain(id, update); } - public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) + public async Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) { RunRecords.Add(new RunRecord(nameof(UpdateSemanticDomain), $"Update semantic domain {after.Id}")); - return _api.UpdateSemanticDomain(before, after, api); + return await _api.UpdateSemanticDomain(before, after, api); } - public Task DeleteSemanticDomain(Guid id) + public async Task DeleteSemanticDomain(Guid id) { RunRecords.Add(new RunRecord(nameof(DeleteSemanticDomain), $"Delete semantic domain {id}")); - return _api.DeleteSemanticDomain(id); + await _api.DeleteSemanticDomain(id); } - public Task CreateComplexFormType(ComplexFormType complexFormType) + public async Task CreateComplexFormType(ComplexFormType complexFormType) { RunRecords.Add(new RunRecord(nameof(CreateComplexFormType), $"Create complex form type {complexFormType.Name}")); - return _api.CreateComplexFormType(complexFormType); + return await _api.CreateComplexFormType(complexFormType); } - public Task UpdateComplexFormType(Guid id, UpdateObjectInput update) + public async Task UpdateComplexFormType(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(UpdateComplexFormType), $"Update complex form type {id}")); - return _api.UpdateComplexFormType(id, update); + return await _api.UpdateComplexFormType(id, update); } - public Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api) + public async Task UpdateComplexFormType(ComplexFormType before, ComplexFormType after, IMiniLcmApi? api) { RunRecords.Add(new RunRecord(nameof(UpdateComplexFormType), $"Update complex form type {after.Id}")); - return _api.UpdateComplexFormType(before, after, api); + return await _api.UpdateComplexFormType(before, after, api); } - public Task DeleteComplexFormType(Guid id) + public async Task DeleteComplexFormType(Guid id) { RunRecords.Add(new RunRecord(nameof(DeleteComplexFormType), $"Delete complex form type {id}")); - return _api.DeleteComplexFormType(id); + await _api.DeleteComplexFormType(id); } - public Task CreateMorphType(MorphType morphType) + public async Task CreateMorphType(MorphType morphType) { RunRecords.Add(new RunRecord(nameof(CreateMorphType), $"Create morph type {morphType.Kind} ({morphType.Id})")); - return _api.CreateMorphType(morphType); + return await _api.CreateMorphType(morphType); } - public Task UpdateMorphType(Guid id, UpdateObjectInput update) + public async Task UpdateMorphType(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(UpdateMorphType), $"Update morph type {id}")); - return _api.UpdateMorphType(id, update); + return await _api.UpdateMorphType(id, update); } - public Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api) + public async Task UpdateMorphType(MorphType before, MorphType after, IMiniLcmApi? api) { RunRecords.Add(new RunRecord(nameof(UpdateMorphType), $"Update morph type {after.Id}")); - return _api.UpdateMorphType(before, after, api); + return await _api.UpdateMorphType(before, after, api); } - public Task CreateEntry(Entry entry, CreateEntryOptions? options = null) + public async Task CreateEntry(Entry entry, CreateEntryOptions? options = null) { RunRecords.Add(new RunRecord(nameof(CreateEntry), $"Create entry {entry.Headword()} ({options ?? new CreateEntryOptions()})")); - return _api.CreateEntry(entry, options); + return await _api.CreateEntry(entry, options); } - public Task UpdateEntry(Guid id, UpdateObjectInput update) + public async Task UpdateEntry(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(UpdateEntry), $"Update entry {id}")); - return _api.UpdateEntry(id, update); + return await _api.UpdateEntry(id, update); } - public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) + public async Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) { RunRecords.Add(new RunRecord(nameof(UpdateEntry), $"Update entry {after.Id}")); - return _api.UpdateEntry(before, after, api); + return await _api.UpdateEntry(before, after, api); } - public Task DeleteEntry(Guid id) + public async Task DeleteEntry(Guid id) { RunRecords.Add(new RunRecord(nameof(DeleteEntry), $"Delete entry {id}")); - return _api.DeleteEntry(id); + await _api.DeleteEntry(id); } - public Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) + public async Task RemoveComplexFormType(Guid entryId, Guid complexFormTypeId) { RunRecords.Add(new RunRecord(nameof(RemoveComplexFormType), $"Remove complex form type {complexFormTypeId}, from entry {entryId}")); - return _api.RemoveComplexFormType(entryId, complexFormTypeId); + await _api.RemoveComplexFormType(entryId, complexFormTypeId); } - public Task CreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) + 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 _api.CreateSense(entryId, sense, position); + return await _api.CreateSense(entryId, sense, position); } - public Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + public async Task UpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(UpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}")); - return _api.UpdateSense(entryId, senseId, update); + return await _api.UpdateSense(entryId, senseId, update); } - public Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api) + public async Task UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api) { RunRecords.Add(new RunRecord(nameof(UpdateSense), $"Update sense {after.Id}")); - return _api.UpdateSense(entryId, before, after, api); + return await _api.UpdateSense(entryId, before, after, api); } - public Task MoveSense(Guid entryId, Guid senseId, BetweenPosition between) + 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}")); - return _api.MoveSense(entryId, senseId, between); + await _api.MoveSense(entryId, senseId, between); } - public Task DeleteSense(Guid entryId, Guid senseId) + public async Task DeleteSense(Guid entryId, Guid senseId) { RunRecords.Add(new RunRecord(nameof(DeleteSense), $"Delete sense {senseId}")); - return _api.DeleteSense(entryId, senseId); + await _api.DeleteSense(entryId, senseId); } - public Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) + public async Task AddSemanticDomainToSense(Guid senseId, SemanticDomain semanticDomain) { RunRecords.Add(new RunRecord(nameof(AddSemanticDomainToSense), $"Add semantic domain {semanticDomain.Name}")); - return _api.AddSemanticDomainToSense(senseId, semanticDomain); + await _api.AddSemanticDomainToSense(senseId, semanticDomain); } - public Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) + public async Task RemoveSemanticDomainFromSense(Guid senseId, Guid semanticDomainId) { RunRecords.Add(new RunRecord(nameof(RemoveSemanticDomainFromSense), $"Remove semantic domain {semanticDomainId}")); - return _api.RemoveSemanticDomainFromSense(senseId, semanticDomainId); + await _api.RemoveSemanticDomainFromSense(senseId, semanticDomainId); } - public Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) + public async Task SetSensePartOfSpeech(Guid senseId, Guid? partOfSpeechId) { RunRecords.Add(new RunRecord(nameof(SetSensePartOfSpeech), $"Set part of speech {partOfSpeechId}")); - return _api.SetSensePartOfSpeech(senseId, partOfSpeechId); + await _api.SetSensePartOfSpeech(senseId, partOfSpeechId); } - public Task CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) + 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 _api.CreateExampleSentence(entryId, senseId, exampleSentence, position); + return await _api.CreateExampleSentence(entryId, senseId, exampleSentence, position); } - public Task UpdateExampleSentence(Guid entryId, + 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 _api.UpdateExampleSentence(entryId, senseId, exampleSentenceId, update); + return await _api.UpdateExampleSentence(entryId, senseId, exampleSentenceId, update); } - public Task UpdateExampleSentence(Guid entryId, + 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 _api.UpdateExampleSentence(entryId, senseId, before, after, api); + return await _api.UpdateExampleSentence(entryId, senseId, before, after, api); } - public Task MoveExampleSentence(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) + 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}")); - return _api.MoveExampleSentence(entryId, senseId, exampleId, between); + await _api.MoveExampleSentence(entryId, senseId, exampleId, between); } - public Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) + public async Task DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) { RunRecords.Add(new RunRecord(nameof(DeleteExampleSentence), $"Delete example sentence {exampleSentenceId}")); - return _api.DeleteExampleSentence(entryId, senseId, exampleSentenceId); + await _api.DeleteExampleSentence(entryId, senseId, exampleSentenceId); } - public Task AddTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Translation translation) + 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}")); - return _api.AddTranslation(entryId, senseId, exampleSentenceId, translation); + await _api.AddTranslation(entryId, senseId, exampleSentenceId, translation); } - public Task RemoveTranslation(Guid entryId, Guid senseId, Guid exampleSentenceId, Guid translationId) + 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}")); - return _api.RemoveTranslation(entryId, senseId, exampleSentenceId, translationId); + await _api.RemoveTranslation(entryId, senseId, exampleSentenceId, translationId); } - public Task UpdateTranslation(Guid entryId, + 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}")); - return _api.UpdateTranslation(entryId, senseId, exampleSentenceId, translationId, update); + await _api.UpdateTranslation(entryId, senseId, exampleSentenceId, translationId, update); } - public Task CreatePicture(Guid entryId, Guid senseId, Picture picture, BetweenPosition? position = null) + 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 _api.CreatePicture(entryId, senseId, picture, position); + return await _api.CreatePicture(entryId, senseId, picture, position); } - public Task UpdatePicture(Guid entryId, + 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 _api.UpdatePicture(entryId, senseId, pictureId, update); + return await _api.UpdatePicture(entryId, senseId, pictureId, update); } - public Task UpdatePicture(Guid entryId, + 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 _api.UpdatePicture(entryId, senseId, before, after, api); + return await _api.UpdatePicture(entryId, senseId, before, after, api); } - public Task MovePicture(Guid entryId, Guid senseId, Guid exampleId, BetweenPosition between) + 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}")); - return _api.MovePicture(entryId, senseId, exampleId, between); + await _api.MovePicture(entryId, senseId, exampleId, between); } - public Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) + public async Task DeletePicture(Guid entryId, Guid senseId, Guid pictureId) { RunRecords.Add(new RunRecord(nameof(DeletePicture), $"Delete picture {pictureId}")); - return _api.DeletePicture(entryId, senseId, pictureId); + await _api.DeletePicture(entryId, senseId, pictureId); } - public Task CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? between = null) + 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 _api.CreateComplexFormComponent(complexFormComponent, between); + return await _api.CreateComplexFormComponent(complexFormComponent, between); } - public Task MoveComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition 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}")); - return _api.MoveComplexFormComponent(complexFormComponent, between); + await _api.MoveComplexFormComponent(complexFormComponent, between); } - public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) + public async Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent) { var componentName = ComplexFormComponentName(complexFormComponent); RunRecords.Add(new RunRecord(nameof(DeleteComplexFormComponent), $"Delete complex form component: {componentName}")); - return _api.DeleteComplexFormComponent(complexFormComponent); + await _api.DeleteComplexFormComponent(complexFormComponent); } - public Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) + public async Task AddComplexFormType(Guid entryId, Guid complexFormTypeId) { RunRecords.Add(new RunRecord(nameof(AddComplexFormType), $"Add complex form type {complexFormTypeId}, to entry {entryId}")); - return _api.AddComplexFormType(entryId, complexFormTypeId); + await _api.AddComplexFormType(entryId, complexFormTypeId); } - public Task CreatePublication(Publication pub) + public async Task CreatePublication(Publication pub) { RunRecords.Add(new RunRecord(nameof(CreatePublication), $"Create publication {pub.Id}")); - return _api.CreatePublication(pub); + return await _api.CreatePublication(pub); } - public Task UpdatePublication(Guid id, UpdateObjectInput update) + public async Task UpdatePublication(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(UpdatePublication), $"Update publication {id}")); - return _api.UpdatePublication(id, update); + return await _api.UpdatePublication(id, update); } - public Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) + public async Task UpdatePublication(Publication before, Publication after, IMiniLcmApi? api = null) { RunRecords.Add(new RunRecord(nameof(UpdatePublication), $"Update publication {before.Id}")); - return _api.UpdatePublication(before, after, api); + return await _api.UpdatePublication(before, after, api); } - public Task DeletePublication(Guid id) + public async Task DeletePublication(Guid id) { RunRecords.Add(new RunRecord(nameof(DeletePublication), $"Delete publication {id}")); - return _api.DeletePublication(id); + await _api.DeletePublication(id); } - public Task AddPublication(Guid entryId, Guid publicationId) + public async Task AddPublication(Guid entryId, Guid publicationId) { RunRecords.Add(new RunRecord(nameof(AddPublication), $"Add publication {publicationId} to entry {entryId}")); - return _api.AddPublication(entryId, publicationId); + await _api.AddPublication(entryId, publicationId); } - public Task RemovePublication(Guid entryId, Guid publicationId) + public async Task RemovePublication(Guid entryId, Guid publicationId) { RunRecords.Add(new RunRecord(nameof(RemovePublication), $"Remove publication {publicationId} from entry {entryId}")); - return _api.RemovePublication(entryId, publicationId); + 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 Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) + public async Task SubmitUpdateEntry(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(SubmitUpdateEntry), $"Update entry {id}")); - return _api.SubmitUpdateEntry(id, update); + await _api.SubmitUpdateEntry(id, update); } - public Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) + public async Task SubmitUpdateSense(Guid entryId, Guid senseId, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(SubmitUpdateSense), $"Update sense {senseId}, changes: {update.Summarize()}")); - return _api.SubmitUpdateSense(entryId, senseId, update); + await _api.SubmitUpdateSense(entryId, senseId, update); } - public Task SubmitUpdateExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId, UpdateObjectInput 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()}")); - return _api.SubmitUpdateExampleSentence(entryId, senseId, exampleSentenceId, update); + await _api.SubmitUpdateExampleSentence(entryId, senseId, exampleSentenceId, update); } - public Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) + public async Task SubmitCreateSense(Guid entryId, Sense sense, BetweenPosition? position = null) { RunRecords.Add(new RunRecord(nameof(SubmitCreateSense), $"Create sense {sense.Gloss}")); - return _api.SubmitCreateSense(entryId, sense, position); + await _api.SubmitCreateSense(entryId, sense, position); } - public Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) + public async Task SubmitCreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position = null) { RunRecords.Add(new RunRecord(nameof(SubmitCreateExampleSentence), $"Create example sentence {exampleSentence.Sentence}")); - return _api.SubmitCreateExampleSentence(entryId, senseId, exampleSentence, position); + await _api.SubmitCreateExampleSentence(entryId, senseId, exampleSentence, position); } - public Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) + public async Task SubmitCreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition? position = null) { RunRecords.Add(new RunRecord(nameof(SubmitCreateComplexFormComponent), $"Create complex form component {ComplexFormComponentName(complexFormComponent)}")); - return _api.SubmitCreateComplexFormComponent(complexFormComponent, position); + await _api.SubmitCreateComplexFormComponent(complexFormComponent, position); } - public Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput update) + public async Task SubmitUpdatePartOfSpeech(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(SubmitUpdatePartOfSpeech), $"Update part of speech {id}")); - return _api.SubmitUpdatePartOfSpeech(id, update); + await _api.SubmitUpdatePartOfSpeech(id, update); } - public Task SubmitUpdatePublication(Guid id, UpdateObjectInput update) + public async Task SubmitUpdatePublication(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(SubmitUpdatePublication), $"Update publication {id}")); - return _api.SubmitUpdatePublication(id, update); + await _api.SubmitUpdatePublication(id, update); } - public Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput update) + public async Task SubmitUpdateSemanticDomain(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(SubmitUpdateSemanticDomain), $"Update semantic domain {id}")); - return _api.SubmitUpdateSemanticDomain(id, update); + await _api.SubmitUpdateSemanticDomain(id, update); } - public Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput update) + public async Task SubmitUpdateComplexFormType(Guid id, UpdateObjectInput update) { RunRecords.Add(new RunRecord(nameof(SubmitUpdateComplexFormType), $"Update complex form type {id}")); - return _api.SubmitUpdateComplexFormType(id, update); + await _api.SubmitUpdateComplexFormType(id, update); } #endregion diff --git a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs index c4ee3f4f3d..e7d903a445 100644 --- a/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs +++ b/backend/FwLite/FwLiteProjectSync/WriteIgnoringMiniLcmApi.cs @@ -48,9 +48,9 @@ public Task CreatePartOfSpeech(PartOfSpeech partOfSpeech) return Task.FromResult(partOfSpeech); } - public Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) + public async Task UpdatePartOfSpeech(Guid id, UpdateObjectInput update) { - return api.GetPartOfSpeech(id)!; + return (await api.GetPartOfSpeech(id))!; } public Task UpdatePartOfSpeech(PartOfSpeech before, PartOfSpeech after, IMiniLcmApi? api) @@ -68,9 +68,9 @@ public Task CreateSemanticDomain(SemanticDomain semanticDomain) return Task.FromResult(semanticDomain); } - public Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) + public async Task UpdateSemanticDomain(Guid id, UpdateObjectInput update) { - return api.GetSemanticDomain(id)!; + return (await api.GetSemanticDomain(id))!; } public Task UpdateSemanticDomain(SemanticDomain before, SemanticDomain after, IMiniLcmApi? api) @@ -127,9 +127,9 @@ public Task CreateEntry(Entry entry, CreateEntryOptions? options) return Task.FromResult(entry with { Components = [], ComplexForms = [] }); } - public Task UpdateEntry(Guid id, UpdateObjectInput update) + public async Task UpdateEntry(Guid id, UpdateObjectInput update) { - return api.GetEntry(id)!; + return (await api.GetEntry(id))!; } public Task UpdateEntry(Entry before, Entry after, IMiniLcmApi? api) From dcdc90f7e7f42ba77e6a1acb2a4c0ac9bb88b7c1 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 28 Jul 2026 15:48:00 +0200 Subject: [PATCH 12/12] Rename OpenTemporaryProjectCopy to OpenTempProjectCopy for consistency with TempCrdtProjectCopy Co-Authored-By: Claude Fable 5 --- .../FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs | 2 +- backend/FwLite/LcmCrdt/CrdtProjectsService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs index b6e10a64cd..e9d9d352ca 100644 --- a/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs +++ b/backend/FwLite/FwLiteProjectSync/CrdtFwdataProjectSyncService.cs @@ -62,7 +62,7 @@ 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.OpenTemporaryProjectCopy(crdt.Project) : null; + 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 diff --git a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs index a235f36cfd..d145988801 100644 --- a/backend/FwLite/LcmCrdt/CrdtProjectsService.cs +++ b/backend/FwLite/LcmCrdt/CrdtProjectsService.cs @@ -287,7 +287,7 @@ public virtual async Task CreateProject(CreateProjectRequest reques /// /// Opens a throwaway copy of a project's database in its own scope and a temp dir /// - public async Task OpenTemporaryProjectCopy(CrdtProject source) + public async Task OpenTempProjectCopy(CrdtProject source) { var tempDir = Path.Combine(Path.GetTempPath(), "FwLiteProjectCopies"); Directory.CreateDirectory(tempDir);