From fae37dd1d022dad6ce34c8f125c571e50448fc02 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 11 Oct 2026 17:15:16 -0400 Subject: [PATCH] fix(data): retry absent pending uploads without changing identity --- OpenNest.Data/NestSaveSession.cs | 11 +++- .../NestHistoryClientTests.cs | 62 +++++++++++++++++++ OpenNest/Document.cs | 2 + OpenNest/Forms/MainForm.cs | 4 +- docs/nest-storage.md | 8 +-- 5 files changed, 78 insertions(+), 9 deletions(-) diff --git a/OpenNest.Data/NestSaveSession.cs b/OpenNest.Data/NestSaveSession.cs index 18d2bc2..b4f26d8 100644 --- a/OpenNest.Data/NestSaveSession.cs +++ b/OpenNest.Data/NestSaveSession.cs @@ -12,9 +12,11 @@ public sealed class NestSaveSession public int Revision { get; private set; } public bool RequiresReload { get; private set; } public Guid PendingCreateId { get; private set; } + public bool PendingCreateWasCopy => PendingCreateId != Guid.Empty && pendingCreateWasCopy; private string pendingCreateUrl = ""; private byte[]? pendingCreateArchive; private NestRecord? pendingCreateMetadata; + private bool pendingCreateWasCopy; public void Bind(Guid id, string serverUrl, int revision) { @@ -34,6 +36,7 @@ public sealed class NestSaveSession PendingCreateId = Guid.Empty; pendingCreateArchive = null; pendingCreateMetadata = null; + pendingCreateWasCopy = false; pendingCreateUrl = ""; } @@ -72,10 +75,11 @@ public sealed class NestSaveSession if (normalizedUrl.Length == 0) throw new ArgumentException("A database server URL is required.", nameof(serverUrl)); - if (PendingCreateId != Guid.Empty && (RequiresReload || normalizedUrl != pendingCreateUrl || saveCopy)) - throw new IOException("The previous upload may have succeeded. Reconcile its pending ID on the original server before saving or copying."); + if (PendingCreateId != Guid.Empty && (RequiresReload || normalizedUrl != pendingCreateUrl + || saveCopy != pendingCreateWasCopy)) + throw new IOException("Reconcile the pending upload on its original server, then retry with the same Save or Save As action."); var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty; - var creating = id == Guid.Empty; + var creating = PendingCreateId != Guid.Empty || id == Guid.Empty; var uploadId = creating ? (PendingCreateId == Guid.Empty ? Guid.NewGuid() : PendingCreateId) : id; var record = NestRecordFactory.FromNest(nest, uploadId, archive.LongLength); if (!creating && (Revision < 1 || RequiresReload)) @@ -86,6 +90,7 @@ public sealed class NestSaveSession pendingCreateUrl = normalizedUrl; pendingCreateArchive = archive.ToArray(); pendingCreateMetadata = record; + pendingCreateWasCopy = saveCopy; } NestRecord saved; try diff --git a/OpenNest.Server.Tests/NestHistoryClientTests.cs b/OpenNest.Server.Tests/NestHistoryClientTests.cs index 0b62d57..2106171 100644 --- a/OpenNest.Server.Tests/NestHistoryClientTests.cs +++ b/OpenNest.Server.Tests/NestHistoryClientTests.cs @@ -66,6 +66,68 @@ public sealed class NestHistoryClientTests Assert.Single(await repository.ListAsync()); } + private sealed class FirstPostFailsBeforeSend : DelegatingHandler + { + private bool failed; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + if (request.Method == HttpMethod.Post && !failed) + { + failed = true; + throw new IOException("Upload was not sent."); + } + return base.SendAsync(request, ct); + } + } + + [Fact] + public async Task AbsentPendingUpload_RetriesSameIdOnlyAfterExplicitReconciliation() + { + using var factory = new ServerFactory(); + using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend()); + using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); + var session = new NestSaveSession(); + var nest = NestApiTests.SyntheticNest(); + var archive = NestApiTests.Archive(nest); + await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); + var pending = session.PendingCreateId; + Assert.Empty(await repository.ListAsync()); + await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); + Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString())); + Assert.False(session.RequiresReload); + var created = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive); + Assert.Equal(pending, created.Id); + Assert.Equal(1, created.Revision); + Assert.Single(await repository.ListAsync()); + } + + [Fact] + public async Task AbsentPendingSaveAs_NeverUpdatesBoundOriginalOnRetry() + { + using var factory = new ServerFactory(); + using var seedHttp = factory.CreateClient(); + using var seed = new RemoteNestRepository(seedHttp, seedHttp.BaseAddress!.ToString()); + var nest = NestApiTests.SyntheticNest(); + var originalBytes = NestApiTests.Archive(nest); + var original = await seed.UploadAsync(originalBytes, new NestRecord { Name = "Original" }); + using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend()); + using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString()); + var session = new NestSaveSession(); + session.Bind(original.Id, http.BaseAddress.ToString(), original.Revision); + nest.Name = "Copied nest"; + var copyBytes = NestApiTests.Archive(nest); + await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true)); + var pending = session.PendingCreateId; + Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString())); + await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes)); + var copy = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true); + Assert.Equal(pending, copy.Id); + Assert.NotEqual(original.Id, copy.Id); + Assert.Equal(1, (await repository.GetMetadataAsync(original.Id))!.Revision); + Assert.Equal(originalBytes, await repository.GetFileAsync(original.Id)); + Assert.Equal(copyBytes, await repository.GetFileAsync(copy.Id)); + } + [Fact] public async Task RealClient_CreatesAndUpdatesOneNestWithExactHistoricalArchives() { diff --git a/OpenNest/Document.cs b/OpenNest/Document.cs index d28e209..d8b3796 100644 --- a/OpenNest/Document.cs +++ b/OpenNest/Document.cs @@ -19,6 +19,8 @@ namespace OpenNest public Guid RemoteId => remoteSession.RemoteId; public Guid PendingCreateId => remoteSession.PendingCreateId; + public bool PendingCreateRequiresReconciliation => remoteSession.PendingCreateId != Guid.Empty && remoteSession.RequiresReload; + public bool PendingCreateWasCopy => remoteSession.PendingCreateWasCopy; public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl); public void BindRemote(Guid id, string serverUrl, int revision) => remoteSession.Bind(id, serverUrl, revision); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 7f0c094..19a6405 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -722,12 +722,12 @@ namespace OpenNest.Forms { var serverUrl = NestStorage.Settings.ServerUrl; var repository = NestStorage.CreateRepository(); - if (form.Document.PendingCreateId != Guid.Empty) + if (form.Document.PendingCreateRequiresReconciliation) { var reconciled = await form.Document.ReconcilePendingCreateAsync(repository, serverUrl); if (!IsDisposed) statusLabel1.Text = reconciled is null - ? "No pending nest was found. Review your work, then Save again to retry the same ID." + ? $"No pending nest was found. Review your work, then {(form.Document.PendingCreateWasCopy ? "Save As" : "Save")} again to retry the same ID." : $"Found saved version {reconciled.Revision}. Review the current nest before saving again."; return; // Never combine reconciliation with a write in the same click. } diff --git a/docs/nest-storage.md b/docs/nest-storage.md index f8e3550..1cec5ed 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -151,10 +151,10 @@ For an ambiguous first upload (including Save As or a server switch), OpenNest keeps the client-generated pending ID and blocks another upload. The next Save checks that exact ID on the original server without writing: if it exists with the exact attempted archive and metadata, the document binds to its revision; -if it is absent, a further explicit Save may retry with the same ID. If it -differs, reopen the server record to review it; do not discard the pending -identity or blindly create another record. Older servers remain read-only for -existing nests until upgraded. +if it is absent, another explicit Save (or Save As for a pending copy) may +retry with the same ID. If it differs, reopen the server record to review it; +do not discard the pending identity or blindly create another record. Older +servers remain read-only for existing nests until upgraded. ## Server integration tests