diff --git a/OpenNest.Data/INestRepository.cs b/OpenNest.Data/INestRepository.cs index d52cdef7..a88a2115 100644 --- a/OpenNest.Data/INestRepository.cs +++ b/OpenNest.Data/INestRepository.cs @@ -75,6 +75,10 @@ public interface INestRepository Task RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException("This repository does not support guarded recovery."); + /// Allocates the year's next generated nest number, unique across every client. + Task AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not allocate nest names."); + /// Atomically replaces the current archive only at the expected revision. Task UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision, CancellationToken cancellationToken = default) => diff --git a/OpenNest.Data/NestNames.cs b/OpenNest.Data/NestNames.cs new file mode 100644 index 00000000..5c17ab40 --- /dev/null +++ b/OpenNest.Data/NestNames.cs @@ -0,0 +1,69 @@ +namespace OpenNest.Data; + +/// +/// 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. +/// +public static class NestNames +{ + private const string Base30Digits = "2345679ACDEFGHJKLMNPQRSTUVWXYZ"; + + /// Earliest and latest years a sequence may be allocated for. + 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}"; + } + + /// + /// The sequence number of a generated name for , or false for + /// any other name (renamed nests, other years, non-canonical padding). + /// + 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; + } +} + +/// Wire shape of the nest server's next-name allocation. +public sealed class NestNameAllocation +{ + public int Year { get; set; } + public int Number { get; set; } + public string Name { get; set; } = ""; +} diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs index 89062520..d4aa3546 100644 --- a/OpenNest.Data/RemoteNestRepository.cs +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -131,6 +131,21 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable return await ReadRecord(response, cancellationToken); } + public async Task 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(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 UpdateFileAsync( Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) { diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 2da3276e..944dd18b 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -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(() => new NestDatabase(factory.DatabasePath, pooling: false)); } diff --git a/OpenNest.Server.Tests/NestSequenceTests.cs b/OpenNest.Server.Tests/NestSequenceTests.cs new file mode 100644 index 00000000..8adb2fa0 --- /dev/null +++ b/OpenNest.Server.Tests/NestSequenceTests.cs @@ -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(() => + new RemoteNestRepository(client, client.BaseAddress!.ToString()).AllocateNestNumberAsync(2100)); + } +} diff --git a/OpenNest.Server/NestDatabase.Sequences.cs b/OpenNest.Server/NestDatabase.Sequences.cs new file mode 100644 index 00000000..fa1ecd77 --- /dev/null +++ b/OpenNest.Server/NestDatabase.Sequences.cs @@ -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; + """; + + /// + /// Allocates the next nest number for , 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. + /// + 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; + } + } +} diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 2c982ccc..3a238d54 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -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(); } } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 677ea6ff..d4151a98 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -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) => { diff --git a/OpenNest.Tests/Data/NestNamesTests.cs b/OpenNest.Tests/Data/NestNamesTests.cs new file mode 100644 index 00000000..c2a9beba --- /dev/null +++ b/OpenNest.Tests/Data/NestNamesTests.cs @@ -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 _)); +} diff --git a/OpenNest.WinForms.Tests/Forms/MainFormCharacterizationTests.cs b/OpenNest.WinForms.Tests/Forms/MainFormCharacterizationTests.cs index 7d345a1b..71f20eca 100644 --- a/OpenNest.WinForms.Tests/Forms/MainFormCharacterizationTests.cs +++ b/OpenNest.WinForms.Tests/Forms/MainFormCharacterizationTests.cs @@ -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(() => { diff --git a/OpenNest/Forms/MainForm.File.cs b/OpenNest/Forms/MainForm.File.cs index 0d35000c..f984e2b2 100644 --- a/OpenNest/Forms/MainForm.File.cs +++ b/OpenNest/Forms/MainForm.File.cs @@ -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) diff --git a/OpenNest/Forms/NewNestFactory.cs b/OpenNest/Forms/NewNestFactory.cs index e42e08f4..8c1e08ae 100644 --- a/OpenNest/Forms/NewNestFactory.cs +++ b/OpenNest/Forms/NewNestFactory.cs @@ -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 { /// - /// Creates File > New nests: machine defaults and clamps, then the next name in the - /// day's sequence ( restarts at 1 each day). + /// Creates File > New nests: machine defaults and clamps, then the year's next name + /// (). In Database mode the nest server allocates the number so + /// every PC shares one sequence; when it cannot, is the + /// local fallback, kept above every number the server handed out and reset each year. /// 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 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; } - /// - /// "N" + two-digit year + the sequence in an unambiguous base-30 alphabet, padded to - /// three digits with its zero digit ('2'). - /// - public static string GetNestName(DateTime date, int id) + private static async Task 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++; } } } diff --git a/docs/nest-storage.md b/docs/nest-storage.md index b5ffc165..3c9c1fa8 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -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: ""`. 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-` (`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.