mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 02:41:06 -04:00
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:
1 parent
d35178c3b5
commit
3bc71a7e87
13 files changed
+329
-50
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
@@ -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(() =>
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user