feat(storage): ask before a first save reuses another nest's name

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>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 11:09:08 -04:00
1 parent 3bc71a7e87
commit c7df3f5e23
9 files changed
+299 -4

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
using System.Net.Http;
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// Duplicate-name check for a nest's first upload. Names may already be on posted CNC
/// programs and labels, so a nest is never renamed silently: callers offer the
/// replacement only when another active nest already has the exact same name.
/// </summary>
public static class NestNameConflicts
{
/// <summary>
/// A freshly allocated name for <paramref name="year"/> when an active server nest
/// already uses <paramref name="name"/>; null when the name is free, or when the
/// check or the allocation fails, so the caller keeps the name it has.
/// </summary>
public static async Task<NestNameAllocation?> FindReplacementAsync(
INestRepository repository, string name, int year, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
if (string.IsNullOrWhiteSpace(name))
return null;
try
{
if (!await IsNameTakenAsync(repository, name, cancellationToken))
return null;
var number = await repository.AllocateNestNumberAsync(year, cancellationToken);
return new NestNameAllocation { Year = year, Number = number, Name = NestNames.Format(year, number) };
}
catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException
or NotSupportedException or ArgumentException or JsonException or InvalidOperationException)
{
return null;
}
}
/// <summary>True when an active (not deleted) server nest is named exactly <paramref name="name"/>.</summary>
public static async Task<bool> IsNameTakenAsync(
INestRepository repository, string name, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
// The search is a case-insensitive substring match; page through it for the exact name.
for (var offset = 0; ;)
{
var page = await repository.QueryAsync(new NestQuery
{
Search = name,
Sort = NestSortField.Name,
Descending = false,
Offset = offset,
Limit = NestQuery.MaxLimit,
}, cancellationToken);
if (page.Items.Any(r => !r.Deleted && string.Equals(r.Name, name, StringComparison.Ordinal)))
return true;
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
return false;
}
}
}
+7
View File
@@ -18,6 +18,13 @@ public sealed class NestSaveSession
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));
@@ -53,6 +53,26 @@ public sealed class NestSequenceTests
Assert.Equal(Enumerable.Range(1, 40), numbers.Order());
}
[Fact]
public async Task DuplicateFirstSaveName_GetsAFreshNameOnlyForActiveExactMatches()
{
using var factory = new ServerFactory();
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
database.Insert(Guid.NewGuid(), new NestRecord { Name = NestNames.Format(2026, 40) }, [1]);
var deleted = database.Insert(Guid.NewGuid(), new NestRecord { Name = "Old job" }, [2]);
database.SetDeleted(deleted.Id, true, deleted.Revision);
}
using var client = factory.CreateClient();
var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var replacement = await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40), 2026);
Assert.Equal(NestNames.Format(2026, 41), replacement?.Name);
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Old job", 2026));
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, NestNames.Format(2026, 40).ToLowerInvariant(), 2026));
}
[Fact]
public async Task Endpoint_RejectsYearsOutsideTheNameRange()
{
@@ -0,0 +1,132 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class NestNameConflictsTests
{
[Fact]
public async Task FreeName_IsKeptWithoutAllocating()
{
var repository = new NamedRepository("N26-223X", "Other N26-223");
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "N26-223", 2026));
Assert.Equal(0, repository.Allocations);
}
[Fact]
public async Task ExactDuplicate_OffersTheNextAllocatedName()
{
var repository = new NamedRepository("N26-223") { NextNumber = 31 };
var replacement = await NestNameConflicts.FindReplacementAsync(repository, "N26-223", 2026);
Assert.NotNull(replacement);
Assert.Equal(2026, replacement!.Year);
Assert.Equal(31, replacement.Number);
Assert.Equal(NestNames.Format(2026, 31), replacement.Name);
Assert.Equal(1, repository.Allocations);
Assert.Equal("N26-223", repository.Queries[0].Search);
}
[Fact]
public async Task MatchIsExact_NotCaseFoldedOrSubstring()
{
var repository = new NamedRepository("n26-223", "N26-2234");
Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "N26-223"));
}
[Fact]
public async Task DeletedNestsDoNotCount()
{
var repository = new NamedRepository();
repository.Records.Add(new NestRecord { Id = Guid.NewGuid(), Name = "Job", Deleted = true });
Assert.False(await NestNameConflicts.IsNameTakenAsync(repository, "Job"));
}
[Fact]
public async Task DuplicateBeyondTheFirstPage_IsFound()
{
var names = Enumerable.Range(0, NestQuery.MaxLimit + 5).Select(i => $"{i:D4} Job").Append("Job");
var repository = new NamedRepository(names.ToArray());
Assert.True(await NestNameConflicts.IsNameTakenAsync(repository, "Job"));
Assert.Equal(2, repository.Queries.Count);
Assert.Equal(NestQuery.MaxLimit, repository.Queries[1].Offset);
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task FailedCheckOrAllocation_KeepsTheCurrentName(bool queryFails, bool allocationFails)
{
var repository = new NamedRepository("Job")
{
QueryFailure = queryFails ? new HttpRequestException("offline") : null,
AllocationFailure = allocationFails ? new NotSupportedException("old server") : null,
};
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Job", 2026));
}
[Fact]
public async Task Timeout_KeepsTheCurrentName()
{
var repository = new NamedRepository("Job");
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Assert.Null(await NestNameConflicts.FindReplacementAsync(repository, "Job", 2026, cancelled.Token));
}
private sealed class NamedRepository : INestRepository
{
public NamedRepository(params string[] names) =>
Records.AddRange(names.Select(n => new NestRecord { Id = Guid.NewGuid(), Name = n }));
public List<NestRecord> Records { get; } = [];
public List<NestQuery> Queries { get; } = [];
public int NextNumber { get; set; } = 1;
public int Allocations { get; private set; }
public Exception? QueryFailure { get; set; }
public Exception? AllocationFailure { get; set; }
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Queries.Add(query);
if (QueryFailure != null)
throw QueryFailure;
// Mirrors the server: case-insensitive substring search over active nests only.
var matches = Records
.Where(r => !r.Deleted && r.Name.Contains(query.NormalizedSearch, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return Task.FromResult(new NestPage
{
Items = matches.Skip(query.Offset).Take(query.Limit).ToList(),
Total = matches.Count,
Offset = query.Offset,
Limit = query.Limit,
});
}
public Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (AllocationFailure != null)
throw AllocationFailure;
Allocations++;
return Task.FromResult(NextNumber++);
}
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
}
}
@@ -30,6 +30,23 @@ public class NestSaveSessionTests
Assert.Equal(2, repository.Uploads);
}
[Fact]
public async Task IsFirstSave_OnlyWhenNothingIsBoundOrPendingOnThatServer()
{
var repository = new FakeRepository();
var session = new NestSaveSession();
Assert.True(session.IsFirstSave("http://shop:8090"));
await session.SaveAsync(repository, "http://shop:8090", new Nest("Job"), [1]);
Assert.False(session.IsFirstSave("http://shop:8090/"));
Assert.True(session.IsFirstSave("http://other:8090"));
repository.Fail = true;
await Assert.ThrowsAsync<IOException>(() =>
session.SaveAsync(repository, "http://other:8090", new Nest("Job"), [1]));
Assert.False(session.IsFirstSave("http://other:8090"));
}
[Fact]
public async Task FailedSaveAndServerSwitch_NeverOverwritePriorIdentity()
{
+1
View File
@@ -22,6 +22,7 @@ namespace OpenNest
public Guid PendingCreateId => remoteSession.PendingCreateId;
public bool PendingCreateRequiresReconciliation => remoteSession.PendingCreateId != Guid.Empty && remoteSession.RequiresReload;
public bool PendingCreateWasCopy => remoteSession.PendingCreateWasCopy;
public bool IsFirstDatabaseSave(string serverUrl) => remoteSession.IsFirstSave(serverUrl);
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);
@@ -1,10 +1,12 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Engine.BestFit;
using OpenNest.IO;
using OpenNest.Properties;
namespace OpenNest.Forms.Commands
{
@@ -15,6 +17,8 @@ namespace OpenNest.Forms.Commands
/// </summary>
internal sealed class NestStorageController
{
private static readonly TimeSpan NameCheckTimeout = TimeSpan.FromSeconds(3);
private readonly Form owner;
private readonly Action<string> showStatus;
@@ -93,6 +97,7 @@ namespace OpenNest.Forms.Commands
private async Task SaveAsync(EditNestForm form, bool saveCopy, string copyName = null)
{
var originalName = form.Nest.Name;
var renamed = false;
SetSaving(true);
try
{
@@ -109,6 +114,18 @@ namespace OpenNest.Forms.Commands
}
if (copyName != null)
form.Nest.Name = copyName;
else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl))
{
var chosen = await ChooseFirstSaveNameAsync(repository, form.Nest.Name);
if (chosen is null)
{
if (!owner.IsDisposed)
showStatus("Save cancelled");
return;
}
renamed = chosen != originalName;
form.Nest.Name = chosen;
}
var saved = await form.Document.SaveToDatabaseAsync(
repository, serverUrl, saveCopy);
if (!form.IsDisposed)
@@ -122,6 +139,9 @@ namespace OpenNest.Forms.Commands
// otherwise a later Save would rename the original nest.
if (copyName != null && !form.Document.PendingCreateWasCopy)
form.Nest.Name = originalName;
// Likewise a duplicate-name rename only sticks once its upload may exist.
if (renamed && form.Document.PendingCreateId == Guid.Empty)
form.Nest.Name = originalName;
if (!owner.IsDisposed)
ShowError("save", ex);
}
@@ -131,6 +151,27 @@ namespace OpenNest.Forms.Commands
}
}
/// <summary>
/// The name for a nest's first upload: its own name unless another server nest
/// already has it, in which case the user confirms or edits a newly allocated name.
/// Null when the user cancels. A failed check keeps the current name.
/// </summary>
private async Task<string> ChooseFirstSaveNameAsync(INestRepository repository, string name)
{
var year = DateTime.Now.Year;
NestNameAllocation replacement;
using (var timeout = new CancellationTokenSource(NameCheckTimeout))
replacement = await NestNameConflicts.FindReplacementAsync(repository, name, year, timeout.Token);
if (replacement is null)
return name;
if (year == Settings.Default.LastNestCreatedDate.Year)
Settings.Default.NestNumber = System.Math.Max(Settings.Default.NestNumber, replacement.Number + 1);
using var dlg = new NestNameForm(replacement.Name, "Save",
$"A nest named \"{name}\" is already on the server. Save this nest as:");
return dlg.ShowDialog(owner) == DialogResult.OK ? dlg.NestName : null;
}
public void ShowError(string action, Exception ex) =>
MessageBox.Show(owner, $"Could not {action} nest: {ex.Message}",
"Nest Storage", MessageBoxButtons.OK, MessageBoxIcon.Error);
+10 -4
View File
@@ -6,15 +6,16 @@ namespace OpenNest.Forms;
/// <summary>
/// Database-mode Save As: asks for the name of the new nest copy, since there is no
/// file dialog to supply one.
/// file dialog to supply one. Save also uses it to rename a first upload whose name
/// another server nest already has.
/// </summary>
public sealed class NestNameForm : Form
{
private readonly TextBox nameBox;
public NestNameForm(string currentName)
public NestNameForm(string currentName, string title = "Save As", string prompt = "New nest name:")
{
Text = "Save As";
Text = title;
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(380, 110);
MinimizeBox = false;
@@ -24,8 +25,9 @@ public sealed class NestNameForm : Form
var label = new Label
{
Text = "New nest name:",
Text = prompt,
AutoSize = true,
MaximumSize = new Size(356, 0),
Location = new Point(12, 16),
};
nameBox = new TextBox
@@ -48,6 +50,10 @@ public sealed class NestNameForm : Form
buttons.Controls.Add(cancel);
buttons.Controls.Add(ok);
// A long prompt wraps; keep the name box below it.
nameBox.Top = System.Math.Max(36, label.Top + label.PreferredHeight + 4);
ClientSize = new Size(ClientSize.Width, nameBox.Bottom + 51);
Controls.Add(nameBox);
Controls.Add(label);
Controls.Add(buttons);
+9
View File
@@ -133,6 +133,15 @@ the nest. It resets when the year changes and is raised past each number the
server hands out. Two PCs naming nests offline at the same time can still pick
the same name; a nest's identity on the server is its `id`, never its name.
A nest's name may already be on posted CNC programs or labels, so it is never
renamed silently. When **Save** (not Save As, and not the reconciliation of a
pending upload) creates a nest's first record on the current server, it first
searches active nests for one with exactly the same name (case-sensitive;
deleted nests do not count, `NestNameConflicts`). Only on a match does it
allocate the year's next name and show it in an editable dialog: OK uploads
under the chosen name, Cancel saves nothing. If the check or the allocation
fails or takes longer than 3 seconds, the nest uploads under its current name.
There is no authentication; this is a LAN-only service. Do not expose it
outside the shop network without adding one.