mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 17:21:34 -04:00
Nests named offline from the per-PC counter can collide with names already on the server. Renaming them silently is not an option because the name may already be on posted CNC programs and labels, so the first Save of a nest to a server now checks for an active nest with exactly the same name and, only then, offers a freshly allocated name in an editable dialog (Cancel saves nothing). A failed or slow check keeps the current name, as before. Save As and pending-upload reconciliation are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
6.4 KiB
C#
134 lines
6.4 KiB
C#
namespace OpenNest.Data;
|
|
|
|
/// <summary>
|
|
/// Tracks a document's database identity, bound to a particular server address.
|
|
/// A failed upload leaves that identity untouched. Switching servers creates a new
|
|
/// record rather than accidentally updating an unrelated record with the same id.
|
|
/// </summary>
|
|
public sealed class NestSaveSession
|
|
{
|
|
public Guid RemoteId { get; private set; }
|
|
public string ServerUrl { get; private set; } = "";
|
|
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;
|
|
|
|
/// <summary>
|
|
/// True when a plain Save to <paramref name="serverUrl"/> would create a new record:
|
|
/// nothing is bound on that server and no earlier upload is pending.
|
|
/// </summary>
|
|
public bool IsFirstSave(string? serverUrl) =>
|
|
PendingCreateId == Guid.Empty && (RemoteId == Guid.Empty || ServerUrl != Normalize(serverUrl));
|
|
|
|
public void Bind(Guid id, string serverUrl, int revision)
|
|
{
|
|
if (revision < 0) throw new ArgumentOutOfRangeException(nameof(revision));
|
|
Bind(id, serverUrl);
|
|
Revision = revision;
|
|
}
|
|
|
|
public void Bind(Guid id, string serverUrl)
|
|
{
|
|
if (id == Guid.Empty)
|
|
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
|
|
RemoteId = id;
|
|
ServerUrl = Normalize(serverUrl);
|
|
Revision = 0; // Legacy reads remain viewable, but must not become guarded saves.
|
|
RequiresReload = false;
|
|
PendingCreateId = Guid.Empty;
|
|
pendingCreateArchive = null;
|
|
pendingCreateMetadata = null;
|
|
pendingCreateWasCopy = false;
|
|
pendingCreateUrl = "";
|
|
}
|
|
|
|
/// <summary>Explicitly reconcile an upload whose reply was lost before any retry.</summary>
|
|
public async Task<NestRecord?> 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<NestRecord> SaveAsync(
|
|
INestRepository repository, string serverUrl, Nest nest, byte[] archive,
|
|
bool saveCopy = false, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(repository);
|
|
ArgumentNullException.ThrowIfNull(nest);
|
|
ArgumentNullException.ThrowIfNull(archive);
|
|
|
|
var normalizedUrl = Normalize(serverUrl);
|
|
if (normalizedUrl.Length == 0)
|
|
throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
|
|
|
|
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 = 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))
|
|
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;
|
|
pendingCreateWasCopy = saveCopy;
|
|
}
|
|
NestRecord saved;
|
|
try
|
|
{
|
|
saved = creating
|
|
? await repository.UploadAsync(archive, record, cancellationToken)
|
|
: await repository.UpdateFileAsync(id, archive, record, Revision, cancellationToken);
|
|
}
|
|
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 != uploadId || (!creating && saved.Revision < Revision))
|
|
{
|
|
RequiresReload = true;
|
|
throw new InvalidDataException("Server returned an invalid nest id or revision. Reload to reconcile before saving again.");
|
|
}
|
|
|
|
Bind(saved.Id, normalizedUrl, saved.Revision);
|
|
return saved;
|
|
}
|
|
|
|
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;
|
|
}
|