feat(server): allocate nest names from one yearly server sequence

File > New restarted its counter every day while names only carry the
year, so the first nest of each day was always N26-223, and each PC kept
its own counter. In Database mode the nest server now hands out the
year's next number (POST /api/nest-names/next, nest_sequences table,
schema v6), staying above every stored generated name so offline or
pre-existing names are never reissued. The per-PC counter remains the
fallback when the server is unreachable or older, resets per year, and
is raised past each server allocation.

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:07 -04:00
1 parent d35178c3b5
commit 3bc71a7e87
13 files changed
+329 -50

No files matched your search

+4
View File
@@ -75,6 +75,10 @@ public interface INestRepository
Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded recovery.");
/// <summary>Allocates the year's next generated nest number, unique across every client.</summary>
Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not allocate nest names.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
+69
View File
@@ -0,0 +1,69 @@
namespace OpenNest.Data;
/// <summary>
/// Generated nest names: "N" + two-digit year + "-" + the year's sequence number in an
/// unambiguous base-30 alphabet, padded to three digits with its zero digit ('2').
/// The sequence runs for the whole year; three digits cover 27,000 nests before a fourth appears.
/// </summary>
public static class NestNames
{
private const string Base30Digits = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
/// <summary>Earliest and latest years a sequence may be allocated for.</summary>
public const int MinYear = 2000, MaxYear = 2099;
public static string Format(int year, int number)
{
ArgumentOutOfRangeException.ThrowIfNegative(number);
var seq = ToBase30(number).PadLeft(3, Base30Digits[0]);
return $"N{year % 100:D2}-{seq}";
}
/// <summary>
/// The sequence number of a generated name for <paramref name="year"/>, or false for
/// any other name (renamed nests, other years, non-canonical padding).
/// </summary>
public static bool TryParse(string? name, int year, out int number)
{
number = 0;
var prefix = $"N{year % 100:D2}-";
if (name is null || name.Length < prefix.Length + 3 || !name.StartsWith(prefix, StringComparison.Ordinal))
return false;
long value = 0;
foreach (var c in name.AsSpan(prefix.Length))
{
var digit = Base30Digits.IndexOf(c);
if (digit < 0)
return false;
value = value * Base30Digits.Length + digit;
if (value > int.MaxValue)
return false;
}
number = (int)value;
return Format(year, number) == name;
}
private static string ToBase30(int value)
{
if (value == 0)
return Base30Digits[0].ToString();
var result = "";
while (value > 0)
{
result = Base30Digits[value % Base30Digits.Length] + result;
value /= Base30Digits.Length;
}
return result;
}
}
/// <summary>Wire shape of the nest server's next-name allocation.</summary>
public sealed class NestNameAllocation
{
public int Year { get; set; }
public int Number { get; set; }
public string Name { get; set; } = "";
}
+15
View File
@@ -131,6 +131,21 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return await ReadRecord(response, cancellationToken);
}
public async Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default)
{
if (year is < NestNames.MinYear or > NestNames.MaxYear)
throw new ArgumentOutOfRangeException(nameof(year));
using var response = await _httpClient.PostAsync(
Url("api/nest-names/next?year=" + year.ToString(CultureInfo.InvariantCulture)), null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotSupportedException("This nest server does not allocate nest names (HTTP 404).");
await EnsureSuccess(response, "allocate a nest name", cancellationToken);
var allocation = await response.Content.ReadFromJsonAsync<NestNameAllocation>(JsonOptions, cancellationToken);
if (allocation is null || allocation.Year != year || allocation.Number < 1)
throw new IOException("Nest server returned an invalid nest name allocation.");
return allocation.Number;
}
public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
@@ -21,7 +21,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
Assert.Equal(5L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -42,7 +42,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(5L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -79,7 +79,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
Execute(factory.DatabasePath, "PRAGMA user_version=6");
Execute(factory.DatabasePath, "PRAGMA user_version=7");
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -0,0 +1,68 @@
using OpenNest.Data;
namespace OpenNest.Server.Tests;
public sealed class NestSequenceTests
{
[Fact]
public void Allocation_CountsPerYearAndSurvivesReopen()
{
using var factory = new ServerFactory();
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(1, database.AllocateNestNumber(2026));
Assert.Equal(2, database.AllocateNestNumber(2026));
Assert.Equal(1, database.AllocateNestNumber(2027));
}
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(3, reopened.AllocateNestNumber(2026));
}
[Fact]
public void Allocation_SkipsPastStoredGeneratedNamesIncludingDeleted()
{
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 = NestNames.Format(2026, 75) }, [2]);
database.SetDeleted(deleted.Id, true, deleted.Revision);
// Another year's name, a renamed nest and non-canonical padding never move the counter.
database.Insert(Guid.NewGuid(), new NestRecord { Name = NestNames.Format(2025, 900) }, [3]);
database.Insert(Guid.NewGuid(), new NestRecord { Name = "N26-ZZZ customer copy" }, [4]);
database.Insert(Guid.NewGuid(), new NestRecord { Name = "N26-2ZZZ" }, [5]);
Assert.Equal(76, database.AllocateNestNumber(2026));
Assert.Equal(77, database.AllocateNestNumber(2026));
// A name uploaded later (an offline fallback) still pushes the counter past it.
database.Insert(Guid.NewGuid(), new NestRecord { Name = NestNames.Format(2026, 200) }, [6]);
Assert.Equal(201, database.AllocateNestNumber(2026));
}
[Fact]
public async Task Allocation_IsUniqueUnderConcurrentClients()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var numbers = await Task.WhenAll(
Enumerable.Range(0, 40).Select(_ => repository.AllocateNestNumberAsync(2026)));
Assert.Equal(Enumerable.Range(1, 40), numbers.Order());
}
[Fact]
public async Task Endpoint_RejectsYearsOutsideTheNameRange()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.PostAsync("/api/nest-names/next?year=1999", null);
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
new RemoteNestRepository(client, client.BaseAddress!.ToString()).AllocateNestNumberAsync(2100));
}
}
+67
View File
@@ -0,0 +1,67 @@
using OpenNest.Data;
namespace OpenNest.Server;
public sealed partial class NestDatabase
{
private const string NestSequenceSchema = """
CREATE TABLE IF NOT EXISTS nest_sequences (
year INTEGER PRIMARY KEY,
next INTEGER NOT NULL
);
PRAGMA user_version=6;
""";
/// <summary>
/// Allocates the next nest number for <paramref name="year"/>, shared by every client.
/// The number is also kept above every stored generated name for the year (active or
/// deleted), so names created offline or before the server counted are never reissued.
/// </summary>
public int AllocateNestNumber(int year)
{
if (year is < NestNames.MinYear or > NestNames.MaxYear)
throw new ArgumentOutOfRangeException(nameof(year));
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var next = 1;
using (var command = _connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = "SELECT next FROM nest_sequences WHERE year=$year";
command.Parameters.AddWithValue("$year", year);
if (command.ExecuteScalar() is long stored)
next = checked((int)stored);
}
using (var command = _connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = "SELECT name FROM nests WHERE name LIKE $prefix";
command.Parameters.AddWithValue("$prefix", $"N{year % 100:D2}-%");
using var reader = command.ExecuteReader();
while (reader.Read())
{
if (NestNames.TryParse(reader.GetString(0), year, out var used) && used >= next)
next = checked(used + 1);
}
}
using (var command = _connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = """
INSERT INTO nest_sequences (year, next) VALUES ($year, $next)
ON CONFLICT(year) DO UPDATE SET next = excluded.next
""";
command.Parameters.AddWithValue("$year", year);
command.Parameters.AddWithValue("$next", checked(next + 1));
command.ExecuteNonQuery();
}
transaction.Commit();
return next;
}
}
}
+6 -1
View File
@@ -319,7 +319,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 5) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 6) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -361,6 +361,11 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = DrawingLibraryUpgradeFromV4;
command.ExecuteNonQuery();
}
if (version < 6)
{
command.CommandText = NestSequenceSchema;
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
+9
View File
@@ -49,6 +49,15 @@ app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
app.MapGet("/api/nests/{id:guid}/file", (Guid id, NestDatabase db) =>
db.GetFile(id) is { } file ? Results.File(file, "application/zip", $"{id}.nest") : Results.NotFound());
// Next generated nest name for a year; one counter per year shared by every client.
app.MapPost("/api/nest-names/next", (int year, NestDatabase db) =>
{
if (year is < NestNames.MinYear or > NestNames.MaxYear)
return Results.BadRequest($"year must be between {NestNames.MinYear} and {NestNames.MaxYear}.");
var number = db.AllocateNestNumber(year);
return Results.Ok(new NestNameAllocation { Year = year, Number = number, Name = NestNames.Format(year, number) });
});
// POST/PUT multipart: "metadata" JSON part + "file" part (the .nest archive).
app.MapPost("/api/nests", async (HttpContext http, NestDatabase db) =>
{
+33
View File
@@ -0,0 +1,33 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class NestNamesTests
{
[Theory]
[InlineData(0, "N26-222")]
[InlineData(1, "N26-223")]
[InlineData(29, "N26-22Z")]
[InlineData(30, "N26-232")]
[InlineData(900, "N26-322")]
[InlineData(27000, "N26-3222")]
public void NamesUseTwoDigitYearAndPaddedBase30Sequence(int number, string expected)
{
Assert.Equal(expected, NestNames.Format(2026, number));
Assert.True(NestNames.TryParse(expected, 2026, out var parsed));
Assert.Equal(number, parsed);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("N25-223")]
[InlineData("N26-23")]
[InlineData("N26-2223")]
[InlineData("N26-22B")]
[InlineData("n26-223")]
[InlineData("N26-223 copy")]
[InlineData("N26-ZZZZZZZZZ")]
public void TryParse_RejectsOtherYearsAndNonCanonicalNames(string? name) =>
Assert.False(NestNames.TryParse(name, 2026, out _));
}
@@ -5,21 +5,11 @@ using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
// Pins MainForm behaviour that its refactor must preserve: nest naming and the deliberately
// different busy guards. Reads Application.OpenForms, so it must not overlap fill-lifetime tests.
// Pins MainForm behaviour that its refactor must preserve: the deliberately different
// busy guards. Reads Application.OpenForms, so it must not overlap fill-lifetime tests.
[Collection("Fill operation lifetime")]
public class MainFormCharacterizationTests
{
[Theory]
[InlineData(0, "N26-222")]
[InlineData(1, "N26-223")]
[InlineData(29, "N26-22Z")]
[InlineData(30, "N26-232")]
[InlineData(900, "N26-322")]
[InlineData(27000, "N26-3222")]
public void NestNamesUseTwoDigitYearAndPaddedBase30Sequence(int id, string expected) =>
Assert.Equal(expected, NewNestFactory.GetNestName(new DateTime(2026, 10, 17), id));
[Fact]
public void BusyGuardsDifferInWhichOperationsTheyCount() => StaTestThread.Run(() =>
{
+2 -2
View File
@@ -34,12 +34,12 @@ namespace OpenNest.Forms
#region File Menu Events
private void New_Click(object sender, EventArgs e)
private async void New_Click(object sender, EventArgs e)
{
var windowState =
ActiveMdiChild != null ? ActiveMdiChild.WindowState : FormWindowState.Maximized;
LoadNest(NewNestFactory.Create(this), windowState);
LoadNest(await NewNestFactory.CreateAsync(this), windowState);
}
private async void Open_Click(object sender, EventArgs e)
+32 -32
View File
@@ -1,32 +1,32 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Properties;
namespace OpenNest.Forms
{
/// <summary>
/// Creates File > New nests: machine defaults and clamps, then the next name in the
/// day's sequence (<see cref="Settings.NestNumber"/> restarts at 1 each day).
/// Creates File > New nests: machine defaults and clamps, then the year's next name
/// (<see cref="NestNames"/>). In Database mode the nest server allocates the number so
/// every PC shares one sequence; when it cannot, <see cref="Settings.NestNumber"/> is the
/// local fallback, kept above every number the server handed out and reset each year.
/// </summary>
internal static class NewNestFactory
{
private const string Base30Digits = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
private static readonly TimeSpan ServerTimeout = TimeSpan.FromSeconds(3);
public static Nest Create(IWin32Window owner)
public static async Task<Nest> CreateAsync(IWin32Window owner)
{
var nest = CreateDefaultNest(owner);
var now = DateTime.Now;
nest.DateCreated = now;
nest.DateLastModified = now;
if (now.Date != Settings.Default.LastNestCreatedDate.Date)
{
Settings.Default.NestNumber = 1;
Settings.Default.LastNestCreatedDate = now.Date;
}
nest.Name = GetNestName(now, Settings.Default.NestNumber++);
nest.Name = NestNames.Format(now.Year, await AllocateNumberAsync(now));
return nest;
}
@@ -39,30 +39,30 @@ namespace OpenNest.Forms
return nest;
}
/// <summary>
/// "N" + two-digit year + the sequence in an unambiguous base-30 alphabet, padded to
/// three digits with its zero digit ('2').
/// </summary>
public static string GetNestName(DateTime date, int id)
private static async Task<int> AllocateNumberAsync(DateTime now)
{
var year = (date.Year % 100).ToString("D2");
var seq = ToBase30(id).PadLeft(3, Base30Digits[0]);
if (now.Year != Settings.Default.LastNestCreatedDate.Year)
Settings.Default.NestNumber = 1;
Settings.Default.LastNestCreatedDate = now.Date;
return $"N{year}-{seq}";
}
private static string ToBase30(int value)
{
if (value == 0)
return Base30Digits[0].ToString();
var result = "";
while (value > 0)
if (NestStorage.Settings.IsDatabaseMode)
{
result = Base30Digits[value % Base30Digits.Length] + result;
value /= Base30Digits.Length;
try
{
using var timeout = new CancellationTokenSource(ServerTimeout);
var number = await NestStorage.CreateRepository()
.AllocateNestNumberAsync(now.Year, timeout.Token);
Settings.Default.NestNumber = System.Math.Max(Settings.Default.NestNumber, number + 1);
return number;
}
catch (Exception ex) when (ex is HttpRequestException or IOException
or OperationCanceledException or NotSupportedException or ArgumentException)
{
// Offline or an older server: fall back to this PC's counter.
}
}
return result;
return Settings.Default.NestNumber++;
}
}
}
+19
View File
@@ -106,6 +106,7 @@ existing callers its integer value 0–2; an undefined value is 400 and stores n
| GET | `/api/nests/deleted/query` | — | Bounded deleted-record page; same query parameters as ordinary browsing |
| GET | `/api/nests/deleted/{id}` / `/{id}/file` / `/{id}/revisions` / `/{id}/revisions/{revision}/file` | — | Recovery equivalents of current/history reads |
| POST | `/api/nests/deleted/{id}/recover` | — | New nondeleted revision of the same ID |
| POST | `/api/nest-names/next?year=` | — | `{ "year", "number", "name" }`: the year's next generated nest number (see below); 400 outside 2000–2099 |
All existing-record mutations require `If-Match: "<current revision>"`.
Missing precondition returns 428 (upgrade/reload), malformed precondition 400,
@@ -114,6 +115,24 @@ of hidden records return 404. Changed file/metadata saves and visibility changes
append snapshots atomically; an identical save at the current revision is a no-op.
Restore appends a new snapshot, never rewrites its source or intervening history.
### Generated nest names
**File > New** names a nest `N<yy>-<seq>` (`OpenNest.Data.NestNames`): the
two-digit year and the year's sequence number in a base-30 alphabet without
look-alike characters, padded to three digits (27,000 a year before a fourth
appears). The sequence runs for the whole year. In Database mode the desktop
asks `POST /api/nest-names/next` for the number, so every PC shares one
counter per year (`nest_sequences`, schema version 6). The server also keeps
the number above every stored generated name for that year, active or
deleted, so names created offline or before the server counted are not
reissued; renamed nests and other years' names do not move it.
When the server is unreachable within 3 seconds, has no such endpoint, or the
app is in File mode, the PC's own counter (`NestNumber` user setting) names
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.
There is no authentication; this is a LAN-only service. Do not expose it
outside the shop network without adding one.