diff --git a/OpenNest.Data/NestSaveSession.cs b/OpenNest.Data/NestSaveSession.cs
index f48d64c..18d2bc2 100644
--- a/OpenNest.Data/NestSaveSession.cs
+++ b/OpenNest.Data/NestSaveSession.cs
@@ -11,6 +11,10 @@ public sealed class NestSaveSession
public string ServerUrl { get; private set; } = "";
public int Revision { get; private set; }
public bool RequiresReload { get; private set; }
+ public Guid PendingCreateId { get; private set; }
+ private string pendingCreateUrl = "";
+ private byte[]? pendingCreateArchive;
+ private NestRecord? pendingCreateMetadata;
public void Bind(Guid id, string serverUrl, int revision)
{
@@ -27,6 +31,33 @@ public sealed class NestSaveSession
ServerUrl = Normalize(serverUrl);
Revision = 0; // Legacy reads remain viewable, but must not become guarded saves.
RequiresReload = false;
+ PendingCreateId = Guid.Empty;
+ pendingCreateArchive = null;
+ pendingCreateMetadata = null;
+ pendingCreateUrl = "";
+ }
+
+ /// Explicitly reconcile an upload whose reply was lost before any retry.
+ public async Task ReconcilePendingCreateAsync(INestRepository repository,
+ string serverUrl, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(repository);
+ if (PendingCreateId == Guid.Empty || Normalize(serverUrl) != pendingCreateUrl)
+ throw new InvalidOperationException("No pending upload on this server to reconcile.");
+ var record = await repository.GetMetadataAsync(PendingCreateId, cancellationToken);
+ if (record is null)
+ {
+ // A deliberate subsequent Save may retry this SAME ID, never create a second ID.
+ RequiresReload = false;
+ return null;
+ }
+ var bytes = await repository.GetFileAsync(PendingCreateId, cancellationToken);
+ if (record.Id != PendingCreateId || record.Revision < 1 || bytes is null
+ || !bytes.AsSpan().SequenceEqual(pendingCreateArchive)
+ || !SameEditableMetadata(record, pendingCreateMetadata!))
+ throw new IOException("The pending upload differs from the server record. Reopen the server nest before editing.");
+ Bind(record.Id, pendingCreateUrl, record.Revision);
+ return record;
}
public async Task SaveAsync(
@@ -41,27 +72,38 @@ 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.");
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
- var record = NestRecordFactory.FromNest(nest, id, archive.LongLength);
- if (id != Guid.Empty && (Revision < 1 || RequiresReload))
+ var creating = 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))
throw new IOException("Reload the current nest revision before saving, or Save As a new nest. Upgrade the nest server if version history is unavailable.");
+ if (creating)
+ {
+ PendingCreateId = uploadId;
+ pendingCreateUrl = normalizedUrl;
+ pendingCreateArchive = archive.ToArray();
+ pendingCreateMetadata = record;
+ }
NestRecord saved;
try
{
- saved = id == Guid.Empty
+ saved = creating
? await repository.UploadAsync(archive, record, cancellationToken)
: await repository.UpdateFileAsync(id, archive, record, Revision, cancellationToken);
}
- catch (Exception ex) when (id != Guid.Empty && ex is IOException or System.Net.Http.HttpRequestException or OperationCanceledException or System.Text.Json.JsonException)
+ catch (Exception ex) when (ex is IOException or System.Net.Http.HttpRequestException or OperationCanceledException or System.Text.Json.JsonException)
{
// The request may have committed before its reply was lost. Do not replay
// pending bytes until the operator explicitly reloads/reconciles the record.
RequiresReload = true;
throw;
}
- if (saved.Id == Guid.Empty || (id != Guid.Empty && (saved.Id != id || saved.Revision < Revision)))
+ if (saved.Id != uploadId || (!creating && saved.Revision < Revision))
{
- if (id != Guid.Empty) RequiresReload = true;
+ RequiresReload = true;
throw new InvalidDataException("Server returned an invalid nest id or revision. Reload to reconcile before saving again.");
}
@@ -70,4 +112,10 @@ public sealed class NestSaveSession
}
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
+
+ private static bool SameEditableMetadata(NestRecord a, NestRecord b) =>
+ a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated
+ && a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness
+ && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount
+ && a.Comments == b.Comments && a.MadeBy == b.MadeBy;
}
diff --git a/OpenNest.Server.Tests/NestHistoryClientTests.cs b/OpenNest.Server.Tests/NestHistoryClientTests.cs
index 559eaaa..0b62d57 100644
--- a/OpenNest.Server.Tests/NestHistoryClientTests.cs
+++ b/OpenNest.Server.Tests/NestHistoryClientTests.cs
@@ -5,6 +5,67 @@ namespace OpenNest.Server.Tests;
public sealed class NestHistoryClientTests
{
+ [Fact]
+ public async Task LostCreateReply_BlocksReplayAndReconcilesSameId()
+ {
+ using var factory = new ServerFactory();
+ using var http = factory.CreateDefaultClient(new LostReplyHandler());
+ 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 pendingId = session.PendingCreateId;
+ Assert.NotEqual(Guid.Empty, pendingId);
+ Assert.True(session.RequiresReload);
+ Assert.Single(await repository.ListAsync());
+ await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
+ Assert.Single(await repository.ListAsync());
+ var reconciled = await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString());
+ Assert.Equal(pendingId, reconciled!.Id);
+ Assert.Equal(pendingId, session.RemoteId);
+ Assert.Equal(Guid.Empty, session.PendingCreateId);
+ Assert.Single(await repository.ListAsync());
+ Assert.Equal(archive, await repository.GetRevisionFileAsync(pendingId, 1));
+ }
+
+ private sealed class LostReplyHandler : DelegatingHandler
+ {
+ private bool lost;
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ var response = await base.SendAsync(request, ct);
+ if (request.Method == HttpMethod.Post && !lost)
+ {
+ lost = true;
+ response.Dispose();
+ throw new IOException("Response lost after commit.");
+ }
+ return response;
+ }
+ }
+
+ [Fact]
+ public async Task LostCreateReply_ChangedServerMetadataCannotBindWithoutReload()
+ {
+ using var factory = new ServerFactory();
+ using var http = factory.CreateDefaultClient(new LostReplyHandler());
+ 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 id = session.PendingCreateId;
+ var current = (await repository.GetMetadataAsync(id))!;
+ current.Comments = "Changed by another editor";
+ await repository.UpdateMetadataAsync(id, current, current.Revision);
+ await Assert.ThrowsAsync(() => session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
+ Assert.Equal(Guid.Empty, session.RemoteId);
+ Assert.True(session.RequiresReload);
+ await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
+ Assert.Single(await repository.ListAsync());
+ }
+
[Fact]
public async Task RealClient_CreatesAndUpdatesOneNestWithExactHistoricalArchives()
{
diff --git a/OpenNest.Tests/Data/NestSaveSessionTests.cs b/OpenNest.Tests/Data/NestSaveSessionTests.cs
index eb1bb68..4525c7f 100644
--- a/OpenNest.Tests/Data/NestSaveSessionTests.cs
+++ b/OpenNest.Tests/Data/NestSaveSessionTests.cs
@@ -43,10 +43,12 @@ public class NestSaveSessionTests
session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]));
Assert.Equal(oldId, session.RemoteId);
Assert.Equal("http://old:8090", session.ServerUrl);
- Assert.Equal(Guid.Empty, repository.LastRecord!.Id);
+ Assert.NotEqual(Guid.Empty, repository.LastRecord!.Id);
+ Assert.Equal(session.PendingCreateId, repository.LastRecord.Id);
Assert.Equal(0, repository.Updates);
repository.Fail = false;
+ Assert.Null(await session.ReconcilePendingCreateAsync(repository, "http://new:8090"));
await session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]);
Assert.NotEqual(oldId, session.RemoteId);
Assert.Equal("http://new:8090", session.ServerUrl);
@@ -95,7 +97,7 @@ public class NestSaveSessionTests
Uploads++;
LastRecord = record;
if (Fail) throw new IOException("Offline");
- record.Id = Guid.NewGuid();
+ if (record.Id == Guid.Empty) record.Id = Guid.NewGuid();
record.Revision = 1;
return Task.FromResult(record);
}
@@ -118,7 +120,7 @@ public class NestSaveSessionTests
public Task> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
public Task QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException();
- public Task GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
+ public Task GetMetadataAsync(Guid id, CancellationToken ct = default) => Task.FromResult(null);
public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
diff --git a/OpenNest/Document.cs b/OpenNest/Document.cs
index f16601e..d28e209 100644
--- a/OpenNest/Document.cs
+++ b/OpenNest/Document.cs
@@ -18,11 +18,15 @@ namespace OpenNest
private readonly NestSaveSession remoteSession = new();
public Guid RemoteId => remoteSession.RemoteId;
-
+ public Guid PendingCreateId => remoteSession.PendingCreateId;
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);
+ public Task ReconcilePendingCreateAsync(INestRepository repository, string serverUrl,
+ CancellationToken cancellationToken = default) =>
+ remoteSession.ReconcilePendingCreateAsync(repository, serverUrl, cancellationToken);
+
public int RemoteRevision => remoteSession.Revision;
public string Name => Nest?.Name;
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index 4cf7f86..7f0c094 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -721,8 +721,18 @@ namespace OpenNest.Forms
try
{
var serverUrl = NestStorage.Settings.ServerUrl;
+ var repository = NestStorage.CreateRepository();
+ if (form.Document.PendingCreateId != Guid.Empty)
+ {
+ 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."
+ : $"Found saved version {reconciled.Revision}. Review the current nest before saving again.";
+ return; // Never combine reconciliation with a write in the same click.
+ }
var saved = await form.Document.SaveToDatabaseAsync(
- NestStorage.CreateRepository(), serverUrl, saveCopy);
+ repository, serverUrl, saveCopy);
if (!form.IsDisposed)
form.Text = form.Document.Name;
if (!IsDisposed)