From 44666b289ddf0f33cad5c7b303a01ac8eeb4aea4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 13:27:27 -0400 Subject: [PATCH] feat(server): record set releases and the nests they made Releasing a drawing set makes one nest per material group, so a kit run spans several nests. The server now records which set revision was released, the multiplier and the nests it made, so a run can be tracked across its nests and a nest can say where it came from. The client uploads every nest first and posts the release last: a failure part-way leaves ordinary nests and no release, never a release pointing at missing nests. For the same reason a release is accepted for an older set revision or an archived set, since the nests already exist by then. The release id is client-allocated and a nest belongs to at most one release, so a retried post gets 409 with the release that already holds it. Releases are append-only and read joined to each nest's current metadata, so status and deletion are always current. The tables join schema v7, as the set tables have not shipped yet. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/SetReleaseNest.cs | 26 ++ OpenNest.Data/SetReleasePage.cs | 14 + OpenNest.Data/SetReleaseRecord.cs | 34 ++ OpenNest.Data/SetReleaseRules.cs | 57 +++ .../DrawingLibraryDatabaseTests.cs | 1 + .../DrawingSetDatabaseTests.cs | 9 +- OpenNest.Server.Tests/DrawingSetFixtures.cs | 36 ++ OpenNest.Server.Tests/SetReleaseTests.cs | 368 ++++++++++++++++++ OpenNest.Server/DrawingSetRequest.cs | 109 +++++- OpenNest.Server/DrawingSetRoutes.cs | 65 +++- OpenNest.Server/NestDatabase.Releases.cs | 246 ++++++++++++ OpenNest.Server/NestDatabase.Sets.cs | 1 + OpenNest.Server/NestDatabase.cs | 37 +- docs/nest-storage.md | 42 +- 14 files changed, 999 insertions(+), 46 deletions(-) create mode 100644 OpenNest.Data/SetReleaseNest.cs create mode 100644 OpenNest.Data/SetReleasePage.cs create mode 100644 OpenNest.Data/SetReleaseRecord.cs create mode 100644 OpenNest.Data/SetReleaseRules.cs create mode 100644 OpenNest.Server.Tests/SetReleaseTests.cs create mode 100644 OpenNest.Server/NestDatabase.Releases.cs diff --git a/OpenNest.Data/SetReleaseNest.cs b/OpenNest.Data/SetReleaseNest.cs new file mode 100644 index 00000000..47b74756 --- /dev/null +++ b/OpenNest.Data/SetReleaseNest.cs @@ -0,0 +1,26 @@ +using OpenNest; + +namespace OpenNest.Data; + +/// One nest of a release and the material group it was made for. +public sealed class SetReleaseNest +{ + public Guid NestId { get; set; } + + public string MaterialName { get; set; } = ""; + + public string MaterialGrade { get; set; } = ""; + + /// Thickness in , stored rounded to four decimals. + public double Thickness { get; set; } + + public Units Units { get; set; } = Units.Inches; + + public CutType CutType { get; set; } = CutType.Laser; + + /// + /// The nest's current metadata, deleted or not, joined by the server when it reads a + /// release, so status and deletion are always up to date. Ignored on post. + /// + public NestRecord? Nest { get; set; } +} diff --git a/OpenNest.Data/SetReleasePage.cs b/OpenNest.Data/SetReleasePage.cs new file mode 100644 index 00000000..f5cb4d15 --- /dev/null +++ b/OpenNest.Data/SetReleasePage.cs @@ -0,0 +1,14 @@ +namespace OpenNest.Data; + +/// One bounded page of set releases, newest first, each with its nests. +public sealed class SetReleasePage +{ + public IReadOnlyList Items { get; init; } = Array.Empty(); + + /// Number of releases of the set across all pages. + public int Total { get; init; } + + public int Offset { get; init; } + + public int Limit { get; init; } +} diff --git a/OpenNest.Data/SetReleaseRecord.cs b/OpenNest.Data/SetReleaseRecord.cs new file mode 100644 index 00000000..166a8ca2 --- /dev/null +++ b/OpenNest.Data/SetReleaseRecord.cs @@ -0,0 +1,34 @@ +namespace OpenNest.Data; + +/// +/// One release of a drawing set revision: the nests made from it, one per material group, +/// with every member quantity multiplied by . Releases are +/// append-only; a mistaken one is undone by deleting its nests, which then read as deleted. +/// +public sealed class SetReleaseRecord +{ + /// Allocated by the client, so an ambiguous post can be retried without a duplicate. + public Guid Id { get; set; } + + /// The set released. Taken from the route on post. + public Guid SetId { get; set; } + + /// The exact set revision released; it need not be the current one. + public int SetRevision { get; set; } + + /// The set's name at . Server-joined; ignored on post. + public string SetName { get; set; } = ""; + + public int Multiplier { get; set; } = 1; + + public string Notes { get; set; } = ""; + + /// When the server stored the release. Server-computed. + public DateTime ReleasedAt { get; set; } + + /// Who released the set, as reported by the client. + public string ReleasedBy { get; set; } = ""; + + /// The nests created by the release, in the order posted. + public IReadOnlyList Nests { get; set; } = Array.Empty(); +} diff --git a/OpenNest.Data/SetReleaseRules.cs b/OpenNest.Data/SetReleaseRules.cs new file mode 100644 index 00000000..8fa15c00 --- /dev/null +++ b/OpenNest.Data/SetReleaseRules.cs @@ -0,0 +1,57 @@ +using OpenNest; + +namespace OpenNest.Data; + +/// +/// Bounds shared by the set release client and server. Whether the set revision and +/// every nest exist, and whether a nest already belongs to a release, is checked by the server. +/// +public static class SetReleaseRules +{ + public const int MaxMultiplier = 1000; + + /// One nest per material group, so never more nests than a set has members. + public const int MaxNests = DrawingSetRules.MaxMembers; + + /// The first rule breaks, or null when it may be posted. + public static string? GetError(SetReleaseRecord? release) + { + if (release is null) + return "Missing release."; + if (release.SetRevision < 1) + return "setRevision must be a positive revision."; + if (release.Multiplier < 1 || release.Multiplier > MaxMultiplier) + return $"multiplier must be between 1 and {MaxMultiplier}."; + var error = DrawingLibraryRules.TextError("releasedBy", release.ReleasedBy, DrawingLibraryRules.MaxTextLength) + ?? DrawingLibraryRules.TextError("notes", release.Notes, DrawingLibraryRules.MaxNotesLength); + if (error is not null) + return error; + if (release.Nests is null || release.Nests.Count == 0) + return "nests must list at least one nest."; + if (release.Nests.Count > MaxNests) + return $"A release may have at most {MaxNests} nests."; + + var seen = new HashSet(); + for (var i = 0; i < release.Nests.Count; i++) + { + var nest = release.Nests[i]; + if (nest is null) + return $"nests[{i}] is required."; + if (nest.NestId == Guid.Empty) + return $"nests[{i}].nestId is required."; + if (!seen.Add(nest.NestId)) + return $"nests[{i}] repeats nest {nest.NestId}."; + error = DrawingLibraryRules.TextError($"nests[{i}].materialName", nest.MaterialName, DrawingLibraryRules.MaxTextLength) + ?? DrawingLibraryRules.TextError($"nests[{i}].materialGrade", nest.MaterialGrade, DrawingLibraryRules.MaxTextLength) + ?? (!double.IsFinite(nest.Thickness) || nest.Thickness < 0 + ? $"nests[{i}].thickness must be a finite number, zero or greater." + : null) + ?? (!Enum.IsDefined(nest.Units) ? $"nests[{i}].units must be a supported unit name." : null) + ?? (!Enum.IsDefined(nest.CutType) ? $"nests[{i}].cutType must be Laser, Waterjet or Plasma." : null); + if (error is not null) + return error; + } + + return null; + } +} diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 4a9e9139..23f2daef 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -236,6 +236,7 @@ public sealed class DrawingLibraryDatabaseTests } Execute(path, """ + DROP TABLE set_release_nests; DROP TABLE set_releases; DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3; """); diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index b7d24261..f2edfd8a 100644 --- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -43,7 +43,7 @@ public sealed class DrawingSetDatabaseTests Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version")); - Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'drawing_set%'")); + Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'drawing_set%' OR name LIKE 'set_release%'")); Assert.Equal(before, Digest(factory.DatabasePath)); Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing"); using var database = new NestDatabase(factory.DatabasePath, pooling: false); @@ -295,7 +295,7 @@ public sealed class DrawingSetDatabaseTests } /// - /// A real schema-v6 file with nests and a library drawing: the set tables are additive, + /// A real schema-v6 file with nests and a library drawing: the set and release tables are additive, /// so dropping them restores v6 exactly. Returns the drawing's id. /// private static Guid CreateVersion6Database(string path) @@ -314,7 +314,10 @@ public sealed class DrawingSetDatabaseTests database.UpdateDrawing(drawing, edit, null, 1); } - Execute(path, "DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; PRAGMA user_version=6;"); + Execute(path, """ + DROP TABLE set_release_nests; DROP TABLE set_releases; + DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; PRAGMA user_version=6; + """); return drawing; } diff --git a/OpenNest.Server.Tests/DrawingSetFixtures.cs b/OpenNest.Server.Tests/DrawingSetFixtures.cs index 26059aa3..cd11d8a4 100644 --- a/OpenNest.Server.Tests/DrawingSetFixtures.cs +++ b/OpenNest.Server.Tests/DrawingSetFixtures.cs @@ -50,4 +50,40 @@ internal static class DrawingSetFixtures public static IEnumerable<(Guid, int)> MembersOf(DrawingSetRecord record) => record.Members.Select(m => (m.DrawingId, m.Quantity)); + + /// Stores a draft nest directly and returns its id. + public static Guid AddNest(NestDatabase database, string name = "Synthetic nest") => + database.Insert(Guid.NewGuid(), new NestRecord { Name = name, Status = NestStatus.Draft }, new byte[] { 1, 2, 3 }).Id; + + public static async Task AddNest(HttpClient client, string name = "Synthetic nest", Guid? id = null) + { + var record = new NestRecord { Id = id ?? Guid.NewGuid(), Name = name, Status = NestStatus.Draft }; + using var body = new MultipartFormDataContent + { + { new StringContent(JsonSerializer.Serialize(record, Options), Encoding.UTF8, "application/json"), "metadata" }, + { new ByteArrayContent(new byte[] { 1, 2, 3 }), "file", "synthetic.nest" }, + }; + using var response = await client.PostAsync("/api/nests", body); + response.EnsureSuccessStatusCode(); + return record.Id; + } + + /// A release of making one nest per id, each in its own material. + public static SetReleaseRecord Release(int setRevision, params Guid[] nests) => new() + { + Id = Guid.NewGuid(), + SetRevision = setRevision, + Multiplier = 2, + Notes = "Synthetic release", + ReleasedBy = "Synthetic operator", + Nests = nests.Select((nest, i) => new SetReleaseNest + { + NestId = nest, + MaterialName = "Synthetic Steel " + i, + MaterialGrade = "Grade A", + Thickness = 0.25, + Units = Units.Inches, + CutType = CutType.Laser, + }).ToList(), + }; } diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs new file mode 100644 index 00000000..f283f59f --- /dev/null +++ b/OpenNest.Server.Tests/SetReleaseTests.cs @@ -0,0 +1,368 @@ +using System.Net; +using System.Net.Http.Json; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Data.Sqlite; +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; +using static OpenNest.Server.Tests.DrawingSetFixtures; + +namespace OpenNest.Server.Tests; + +public sealed class SetReleaseTests +{ + [Fact] + public void Release_OfAStaleRevision_IsStoredAndJoinsItsSetNameAndNests() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var drawing = AddDrawing(database); + var set = Set("Kit as released", members: (drawing, 1)); + database.InsertDrawingSet(set.Id, set); + database.UpdateDrawingSet(set.Id, Set("Kit renamed later", set.Id, (drawing, 3)), 1); + var first = AddNest(database, "Nest one"); + var second = AddNest(database, "Nest two"); + var release = Release(1, first, second); + release.Nests[1].Thickness = 0.123456; + release.Nests[1].Units = Units.Millimeters; + release.Nests[1].CutType = CutType.Waterjet; + + var stored = database.InsertSetRelease(set.Id, release)!; + + Assert.Equal((release.Id, set.Id, 1, "Kit as released", 2), (stored.Id, stored.SetId, stored.SetRevision, stored.SetName, stored.Multiplier)); + Assert.Equal(("Synthetic release", "Synthetic operator"), (stored.Notes, stored.ReleasedBy)); + Assert.NotEqual(default, stored.ReleasedAt); + Assert.Equal(new[] { first, second }, stored.Nests.Select(n => n.NestId)); + Assert.Equal(("Synthetic Steel 1", "Grade A", 0.1235, Units.Millimeters, CutType.Waterjet), + (stored.Nests[1].MaterialName, stored.Nests[1].MaterialGrade, stored.Nests[1].Thickness, stored.Nests[1].Units, stored.Nests[1].CutType)); + Assert.Equal(("Nest one", NestStatus.Draft, false), (stored.Nests[0].Nest!.Name, stored.Nests[0].Nest!.Status, stored.Nests[0].Nest!.Deleted)); + Assert.Equal(release.Id, database.ReleaseForNest(second)!.Id); + Assert.Null(database.ReleaseForNest(AddNest(database))); + } + + [Fact] + public void JoinedNests_FollowStatusChangesAndDeletion() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + database.InsertDrawingSet(set.Id, set); + var cut = AddNest(database, "Cut nest"); + var removed = AddNest(database, "Removed nest"); + database.InsertSetRelease(set.Id, Release(1, cut, removed)); + + var record = database.Get(cut)!; + record.Status = NestStatus.HasBeenCut; + database.UpdateMetadata(cut, record, 1); + database.SetDeleted(removed, true, 1); + + var nests = database.SetReleases(set.Id, 0, 100)!.Items.Single().Nests; + Assert.Equal((NestStatus.HasBeenCut, false), (nests[0].Nest!.Status, nests[0].Nest!.Deleted)); + Assert.True(nests[1].Nest!.Deleted); + Assert.True(database.ReleaseForNest(removed)!.Nests[1].Nest!.Deleted); + } + + [Fact] + public void Releases_PageNewestFirstPerSetAndAcceptAnArchivedSet() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + var other = Set("Other kit"); + database.InsertDrawingSet(set.Id, set); + database.InsertDrawingSet(other.Id, other); + var ids = new List(); + for (var i = 0; i < 3; i++) + { + var release = Release(1, AddNest(database)); + ids.Add(release.Id); + database.InsertSetRelease(set.Id, release); + Thread.Sleep(5); + } + database.InsertSetRelease(other.Id, Release(1, AddNest(database))); + database.SetDrawingSetArchived(set.Id, true, 1); + var afterArchive = Release(1, AddNest(database)); + Assert.NotNull(database.InsertSetRelease(set.Id, afterArchive)); + ids.Add(afterArchive.Id); + + var page = database.SetReleases(set.Id, 1, 2)!; + Assert.Equal(4, page.Total); + Assert.Equal(new[] { ids[2], ids[1] }, page.Items.Select(r => r.Id)); + Assert.Single(database.SetReleases(other.Id, 0, 100)!.Items); + Assert.Null(database.SetReleases(Guid.NewGuid(), 0, 100)); + Assert.Throws(() => database.SetReleases(set.Id, 0, 201)); + } + + [Fact] + public void Refusals_LeaveNoState() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + database.InsertDrawingSet(set.Id, set); + var free = AddNest(database); + var taken = AddNest(database); + var deleted = AddNest(database); + database.SetDeleted(deleted, true, 1); + var owner = Release(1, taken); + database.InsertSetRelease(set.Id, owner); + + Assert.Null(database.InsertSetRelease(Guid.NewGuid(), Release(1, free))); + Assert.Contains("no revision 2", Assert.Throws(() => database.InsertSetRelease(set.Id, Release(2, free))).Message); + Assert.Contains("does not exist", Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1, free, Guid.NewGuid()))).Message); + Assert.Contains("is deleted", Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1, free, deleted))).Message); + Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1, free, free))); + Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1))); + var noId = Release(1, free); + noId.Id = Guid.Empty; + Assert.Throws(() => database.InsertSetRelease(set.Id, noId)); + var tooMany = Release(1, free); + tooMany.Multiplier = SetReleaseRules.MaxMultiplier + 1; + Assert.Throws(() => database.InsertSetRelease(set.Id, tooMany)); + + var claimed = Assert.Throws(() => database.InsertSetRelease(set.Id, Release(1, free, taken))); + Assert.Equal(owner.Id, claimed.Current.Id); + var reused = Release(1, free); + reused.Id = owner.Id; + Assert.Equal(owner.Id, Assert.Throws(() => database.InsertSetRelease(set.Id, reused)).Current.Id); + + Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM set_releases")); + Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM set_release_nests")); + Assert.Null(database.ReleaseForNest(free)); + } + + // Code-level ownership gate, as for the set operations. + [Theory] + [InlineData("InsertSetRelease")] + [InlineData("SetReleases")] + [InlineData("ReleaseForNest")] + public async Task Concurrency_EveryReleaseOperationWaitsForTheSameMonitor(string operation) + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + database.InsertDrawingSet(set.Id, set); + var nest = AddNest(database); + var next = AddNest(database); + database.InsertSetRelease(set.Id, Release(1, nest)); + var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var worker = new Thread(() => completed.SetResult(Xunit.Record.Exception(() => + { + switch (operation) + { + case "InsertSetRelease": database.InsertSetRelease(set.Id, Release(1, next)); break; + case "SetReleases": database.SetReleases(set.Id, 0, 100); break; + case "ReleaseForNest": database.ReleaseForNest(nest); break; + default: throw new InvalidOperationException($"Unknown test operation: {operation}"); + } + }))) + { IsBackground = true }; + + bool observedWaiting; + bool completedWhileHeld; + lock (sync) + { + worker.Start(); + observedWaiting = SpinWait.SpinUntil(() => completed.Task.IsCompleted || + (worker.ThreadState & ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(10)); + completedWhileHeld = completed.Task.IsCompleted; + } + + Assert.True(worker.Join(TimeSpan.FromSeconds(10)), "Operation did not finish after releasing the monitor."); + Assert.True(observedWaiting, "Caller never reached the operation within the watchdog."); + Assert.False(completedWhileHeld, operation + " bypassed the shared monitor."); + Assert.Null(await completed.Task); + } + + [Fact] + public void Concurrency_AllReleaseSqlStepsOwnTheMonitor() + { + using var factory = new ServerFactory(); + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + var set = Set(); + database.InsertDrawingSet(set.Id, set); + var nest = AddNest(database); + var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var connection = (SqliteConnection)typeof(NestDatabase) + .GetField("_connection", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!; + var steps = 0; + var unownedSteps = 0; + SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 1, _ => + { + steps++; + if (!Monitor.IsEntered(sync)) + unownedSteps++; + return 0; + }, null); + try + { + Action[] operations = + [ + () => database.InsertSetRelease(set.Id, Release(1, nest)), + () => database.SetReleases(set.Id, 0, 100), + () => database.ReleaseForNest(nest), + ]; + foreach (var operation in operations) + { + var before = steps; + operation(); + Assert.True(steps > before, "Operation did not execute the observed SQLite VM."); + Assert.Equal(0, unownedSteps); + } + } + finally + { + SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 0, null, null); + } + } + + [Fact] + public async Task Api_PostsAndReadsReleasesWithCamelCaseEnums() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var set = await Create(client, Set()); + var first = await AddNest(client, "Nest one"); + var second = await AddNest(client, "Nest two"); + var release = Release(1, first, second); + release.Nests[1].Units = Units.Millimeters; + release.Nests[1].CutType = CutType.Plasma; + + using var posted = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(release, Options))); + + Assert.Equal(HttpStatusCode.OK, posted.StatusCode); + var json = JsonNode.Parse(await posted.Content.ReadAsStringAsync())!; + Assert.Equal(set.Id.ToString(), json["setId"]!.GetValue()); + Assert.Equal("Synthetic kit", json["setName"]!.GetValue()); + Assert.Equal("millimeters", json["nests"]![1]!["units"]!.GetValue()); + Assert.Equal("plasma", json["nests"]![1]!["cutType"]!.GetValue()); + Assert.Equal("draft", json["nests"]![0]!["nest"]!["status"]!.GetValue()); + + var page = (await client.GetFromJsonAsync($"/api/sets/{set.Id}/releases", Options))!; + Assert.Equal(release.Id, page.Items.Single().Id); + Assert.Equal("Nest two", page.Items.Single().Nests[1].Nest!.Name); + var fromNest = (await client.GetFromJsonAsync($"/api/nests/{second}/release", Options))!; + Assert.Equal((release.Id, 2), (fromNest.Id, fromNest.Multiplier)); + + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{await AddNest(client)}/release")).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{Guid.NewGuid()}/releases")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/releases?limit=0")).StatusCode); + using var unknownSet = await client.PostAsync($"/api/sets/{Guid.NewGuid()}/releases", + Json(JsonSerializer.Serialize(Release(1, await AddNest(client)), Options))); + Assert.Equal(HttpStatusCode.NotFound, unknownSet.StatusCode); + } + + [Fact] + public async Task Api_Conflicts_ReturnTheReleaseHoldingTheIdOrNest() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var set = await Create(client, Set()); + var taken = await AddNest(client); + var owner = Release(1, taken); + using (await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(owner, Options)))) { } + + var reused = Release(1, await AddNest(client)); + reused.Id = owner.Id; + foreach (var body in new[] { Release(1, await AddNest(client), taken), reused }) + { + using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(body, Options))); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(owner.Id, (await response.Content.ReadFromJsonAsync(Options))!.Id); + } + + Assert.Equal(1, (await client.GetFromJsonAsync($"/api/sets/{set.Id}/releases", Options))!.Total); + } + + public static TheoryData RejectedReleases() + { + var valid = JsonSerializer.Serialize(Release(1, NestA), Options); + string With(string property, JsonNode? value) + { + var node = JsonNode.Parse(valid)!.AsObject(); + node[property] = value; + return node.ToJsonString(); + } + string WithNest(string property, JsonNode? value) + { + var node = JsonNode.Parse(valid)!.AsObject(); + node["nests"]![0]![property] = value; + return node.ToJsonString(); + } + string Without(string property) + { + var node = JsonNode.Parse(valid)!.AsObject(); + node.Remove(property); + return node.ToJsonString(); + } + + return new TheoryData + { + { "empty id", With("id", Guid.Empty.ToString()) }, + { "other set id", With("setId", Guid.NewGuid().ToString()) }, + { "zero set revision", With("setRevision", 0) }, + { "unknown set revision", With("setRevision", 2) }, + { "zero multiplier", With("multiplier", 0) }, + { "multiplier over the limit", With("multiplier", 1001) }, + { "long notes", With("notes", new string('x', 4001)) }, + { "null released by", With("releasedBy", null) }, + { "missing nests", Without("nests") }, + { "null nests", With("nests", null) }, + { "no nests", With("nests", new JsonArray()) }, + { "repeated nest", With("nests", new JsonArray(JsonNode.Parse(valid)!["nests"]![0]!.DeepClone(), JsonNode.Parse(valid)!["nests"]![0]!.DeepClone())) }, + { "unknown nest", WithNest("nestId", Guid.NewGuid().ToString()) }, + { "empty nest id", WithNest("nestId", Guid.Empty.ToString()) }, + { "integer units", WithNest("units", 0) }, + { "unknown units", WithNest("units", "Feet") }, + { "combined cut type", WithNest("cutType", "Laser, Plasma") }, + { "integer cut type", WithNest("cutType", 1) }, + { "negative thickness", WithNest("thickness", -1) }, + { "null material", WithNest("materialName", null) }, + { "long grade", WithNest("materialGrade", new string('g', 201)) }, + { "not an object", "[1]" }, + { "malformed", "{" }, + }; + } + + private static readonly Guid NestA = Guid.Parse("00000000-0000-0000-0000-0000000000a1"); + + [Theory] + [MemberData(nameof(RejectedReleases))] + public async Task Api_InvalidRelease_IsRejectedWithNoState(string row, string json) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var set = await Create(client, Set()); + await AddNest(client, id: NestA); + + using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(json)); + + Assert.True(response.StatusCode == HttpStatusCode.BadRequest, row + ": " + response.StatusCode); + Assert.Equal(0, (await client.GetFromJsonAsync($"/api/sets/{set.Id}/releases", Options))!.Total); + } + + [Fact] + public async Task Api_JoinedNestInTheBody_IsIgnored() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var set = await Create(client, Set()); + var node = JsonNode.Parse(JsonSerializer.Serialize(Release(1, await AddNest(client)), Options))!; + node["nests"]![0]!["nest"] = new JsonObject { ["status"] = 99, ["name"] = 5 }; + + using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(node.ToJsonString())); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static object? Scalar(string path, string sql) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } +} diff --git a/OpenNest.Server/DrawingSetRequest.cs b/OpenNest.Server/DrawingSetRequest.cs index 9ee2997c..18569dc7 100644 --- a/OpenNest.Server/DrawingSetRequest.cs +++ b/OpenNest.Server/DrawingSetRequest.cs @@ -1,5 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using OpenNest; using OpenNest.Data; namespace OpenNest.Server; @@ -21,6 +23,13 @@ internal static class DrawingSetRequest PropertyNameCaseInsensitive = true, }; + // Integer enum values are refused; names are additionally checked before deserializing. + private static readonly JsonSerializerOptions ReleaseOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) }, + }; + public static bool TryParseQuery(IQueryCollection values, out DrawingSetQuery query, out string error) { query = new DrawingSetQuery(); @@ -109,23 +118,12 @@ internal static class DrawingSetRequest return false; } - var members = root.FirstOrDefault(p => string.Equals(p.Key, "members", StringComparison.OrdinalIgnoreCase)); - if (members.Key is null) + if (!RemoveJoined(root, "members", "drawing")) { error = "members is required."; return false; } - if (members.Value is JsonArray array) - { - foreach (var member in array.OfType()) - { - foreach (var key in member.Select(p => p.Key) - .Where(k => string.Equals(k, "drawing", StringComparison.OrdinalIgnoreCase)).ToList()) - member.Remove(key); - } - } - record = root.Deserialize(SetOptions) ?? record; } catch (JsonException) @@ -137,4 +135,91 @@ internal static class DrawingSetRequest error = DrawingSetRules.GetError(record) ?? ""; return error.Length == 0; } + + /// + /// Reads and validates one release JSON object. nests must be present, each + /// nest's units and cutType must be single names, and each nest's joined + /// nest is ignored. + /// + public static bool TryReadRelease(string? json, out SetReleaseRecord release, out string error) + { + release = new SetReleaseRecord(); + if (string.IsNullOrWhiteSpace(json)) + { + error = "Missing release."; + return false; + } + + try + { + if (JsonNode.Parse(json) is not JsonObject root) + { + error = "The release must be a JSON object."; + return false; + } + + if (!RemoveJoined(root, "nests", "nest")) + { + error = "nests is required."; + return false; + } + + // Enum converters also accept comma-separated combinations; allow single names only. + if (Property(root, "nests") is JsonArray nests) + { + for (var i = 0; i < nests.Count; i++) + { + if (nests[i] is not JsonObject nest) + continue; + if (Property(nest, "units") is { } units && !IsName(units)) + { + error = $"nests[{i}].units must be a supported unit name."; + return false; + } + + if (Property(nest, "cutType") is { } cutType && !IsName(cutType)) + { + error = $"nests[{i}].cutType must be Laser, Waterjet or Plasma."; + return false; + } + } + } + + release = root.Deserialize(ReleaseOptions) ?? release; + } + catch (JsonException) + { + error = "Invalid release JSON."; + return false; + } + + error = SetReleaseRules.GetError(release) ?? ""; + return error.Length == 0; + } + + private static JsonNode? Property(JsonObject node, string name) => + node.FirstOrDefault(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)).Value; + + private static bool IsName(JsonNode value) + where TEnum : struct, Enum => + value is JsonValue text && text.TryGetValue(out var name) && DrawingLibraryRequest.TryEnumName(name, out _); + + // Drops the server-joined property from each object in the list, so a record read from + // the server may be sent back; false when the list property is missing altogether. + private static bool RemoveJoined(JsonObject root, string list, string joined) + { + if (!root.Any(p => string.Equals(p.Key, list, StringComparison.OrdinalIgnoreCase))) + return false; + if (Property(root, list) is JsonArray items) + { + foreach (var item in items.OfType()) + { + foreach (var key in item.Select(p => p.Key) + .Where(k => string.Equals(k, joined, StringComparison.OrdinalIgnoreCase)).ToList()) + item.Remove(key); + } + } + + return true; + } } diff --git a/OpenNest.Server/DrawingSetRoutes.cs b/OpenNest.Server/DrawingSetRoutes.cs index 08b5b5b3..5542cac5 100644 --- a/OpenNest.Server/DrawingSetRoutes.cs +++ b/OpenNest.Server/DrawingSetRoutes.cs @@ -4,9 +4,10 @@ using OpenNest.Data; namespace OpenNest.Server; /// -/// /api/sets: named, versioned lists of library drawings with quantities. Sets -/// travel as JSON; existing-set writes follow the drawing If-Match contract (428 missing, -/// 400 malformed, 409 stale or wrong state with the current record). +/// /api/sets: named, versioned lists of library drawings with quantities, and the +/// append-only records of their releases into nests. Both travel as JSON; existing-set +/// writes follow the drawing If-Match contract (428 missing, 400 malformed, 409 stale or +/// wrong state with the current record). /// internal static class DrawingSetRoutes { @@ -68,6 +69,35 @@ internal static class DrawingSetRoutes app.MapPost("/api/sets/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) => SetArchived(id, false, request, db)); + + // Posted after every nest is uploaded; the client allocates the release id. + app.MapPost("/api/sets/{id:guid}/releases", async (Guid id, HttpRequest request, NestDatabase db) => + { + var (release, error) = await ReadRelease(request); + if (error is not null) return error; + if (release!.Id == Guid.Empty) + return Results.BadRequest("id is required: allocate it before posting the release."); + if (release.SetId != Guid.Empty && release.SetId != id) + return Results.BadRequest("Release setId does not match the route."); + try + { + var stored = db.InsertSetRelease(id, release); + return stored is null ? Results.NotFound() : Results.Ok(stored); + } + catch (SetReleaseConflictException conflict) + { + return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict); + } + catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); } + }); + + app.MapGet("/api/sets/{id:guid}/releases", (Guid id, HttpRequest request, NestDatabase db) => + !DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error) + ? Results.BadRequest(error) + : db.SetReleases(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound()); + + app.MapGet("/api/nests/{id:guid}/release", (Guid id, NestDatabase db) => + db.ReleaseForNest(id) is { } release ? Results.Ok(release) : Results.NotFound()); } private static IResult SetArchived(Guid id, bool archived, HttpRequest request, NestDatabase db) @@ -86,11 +116,29 @@ internal static class DrawingSetRoutes Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict); private static async Task<(DrawingSetRecord? Record, IResult? Error)> ReadSet(HttpRequest request) + { + var (json, error) = await ReadJson(request, "set"); + if (error is not null) return (null, error); + return DrawingSetRequest.TryReadSet(json, out var record, out var invalid) + ? (record, null) + : (null, Results.BadRequest(invalid)); + } + + private static async Task<(SetReleaseRecord? Release, IResult? Error)> ReadRelease(HttpRequest request) + { + var (json, error) = await ReadJson(request, "release"); + if (error is not null) return (null, error); + return DrawingSetRequest.TryReadRelease(json, out var release, out var invalid) + ? (release, null) + : (null, Results.BadRequest(invalid)); + } + + private static async Task<(string? Json, IResult? Error)> ReadJson(HttpRequest request, string what) { if (!request.HasJsonContentType()) - return (null, Results.BadRequest("Expected an application/json set.")); + return (null, Results.BadRequest($"Expected an application/json {what}.")); if (request.ContentLength > DrawingSetRules.MaxBodyBytes) - return (null, Results.BadRequest("The set is too large.")); + return (null, Results.BadRequest($"The {what} is too large.")); // Bounded read: a body without a length, or longer than it claims, stops at the limit. using var body = new MemoryStream(); @@ -99,13 +147,10 @@ internal static class DrawingSetRoutes while ((read = await request.Body.ReadAsync(chunk, request.HttpContext.RequestAborted)) > 0) { if (body.Length + read > DrawingSetRules.MaxBodyBytes) - return (null, Results.BadRequest("The set is too large.")); + return (null, Results.BadRequest($"The {what} is too large.")); body.Write(chunk, 0, read); } - var json = Encoding.UTF8.GetString(body.GetBuffer(), 0, (int)body.Length); - return DrawingSetRequest.TryReadSet(json, out var record, out var error) - ? (record, null) - : (null, Results.BadRequest(error)); + return (Encoding.UTF8.GetString(body.GetBuffer(), 0, (int)body.Length), null); } } diff --git a/OpenNest.Server/NestDatabase.Releases.cs b/OpenNest.Server/NestDatabase.Releases.cs new file mode 100644 index 00000000..d1010614 --- /dev/null +++ b/OpenNest.Server/NestDatabase.Releases.cs @@ -0,0 +1,246 @@ +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Set release storage. set_releases records which set revision was released and +/// how many times over; set_release_nests the nests it made, in order, with the +/// material group each was made for. A nest belongs to at most one release. Releases are +/// append-only and read joined to each nest's current metadata. +/// +public sealed partial class NestDatabase +{ + // Part of the v7 set schema. + private const string SetReleaseTables = """ + CREATE TABLE set_releases ( + id TEXT NOT NULL PRIMARY KEY, + setId TEXT NOT NULL, + setRevision INTEGER NOT NULL, + multiplier INTEGER NOT NULL, + notes TEXT NOT NULL DEFAULT '', + releasedAt TEXT NOT NULL, + releasedBy TEXT NOT NULL DEFAULT '', + FOREIGN KEY (setId, setRevision) REFERENCES drawing_set_revisions(id, revision)); + CREATE TABLE set_release_nests ( + releaseId TEXT NOT NULL REFERENCES set_releases(id), + position INTEGER NOT NULL, + nestId TEXT NOT NULL REFERENCES nests(id), + materialName TEXT NOT NULL DEFAULT '', + materialGrade TEXT NOT NULL DEFAULT '', + thickness REAL NOT NULL, + units TEXT NOT NULL, + cutType TEXT NOT NULL, + PRIMARY KEY (releaseId, position)); + CREATE INDEX ix_set_releases_set ON set_releases(setId, releasedAt DESC); + CREATE UNIQUE INDEX ix_set_release_nests_nest ON set_release_nests(nestId); + """; + + private const string ReleaseSelect = """ + SELECT x.id, x.setId, x.setRevision, x.multiplier, x.notes, x.releasedAt, x.releasedBy, COALESCE(v.name, '') + FROM set_releases x LEFT JOIN drawing_set_revisions v ON v.id = x.setId AND v.revision = x.setRevision + """; + + private static readonly string JoinedNestColumns = + string.Join(", ", RecordColumns.Split(',').Select(column => "n." + column.Trim())); + + /// + /// Records a release of . Returns null for an unknown set. The + /// set revision must exist but need not be current, and the set may be archived: the + /// nests already exist, so a release is never refused for a state the operator cannot fix. + /// + public SetReleaseRecord? InsertSetRelease(Guid setId, SetReleaseRecord release) + { + ArgumentNullException.ThrowIfNull(release); + if (release.Id == Guid.Empty) throw new ArgumentException("A release id is required."); + var error = SetReleaseRules.GetError(release); + if (error is not null) + throw new ArgumentException(error); + + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + if (GetDrawingSet(setId) is null) return null; + if (ReadRelease(release.Id) is { } existing) + throw new SetReleaseConflictException(existing, "A release with this id already exists."); + using (var command = _connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = "SELECT 1 FROM drawing_set_revisions WHERE id=$id AND revision=$revision"; + command.Parameters.AddWithValue("$id", setId.ToString()); + command.Parameters.AddWithValue("$revision", release.SetRevision); + if (command.ExecuteScalar() is null) + throw new ArgumentException($"The set has no revision {release.SetRevision}."); + } + + foreach (var nest in release.Nests) + { + if (Get(nest.NestId) is null) + { + throw new ArgumentException(Get(nest.NestId, deleted: true) is null + ? $"Nest {nest.NestId} does not exist." + : $"Nest {nest.NestId} is deleted."); + } + + if (ReleaseForNest(nest.NestId) is { } owner) + throw new SetReleaseConflictException(owner, $"Nest {nest.NestId} already belongs to a release."); + } + + WriteRelease(setId, release, transaction); + transaction.Commit(); + return ReadRelease(release.Id) ?? throw new InvalidOperationException("Release did not persist."); + } + } + + /// One page of the set's releases, newest first, or null for an unknown set. + public SetReleasePage? SetReleases(Guid setId, int offset, int limit) + { + if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) + throw new ArgumentOutOfRangeException(nameof(limit)); + lock (_sync) + { + if (GetDrawingSet(setId) is null) return null; + using var count = _connection.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM set_releases WHERE setId=$id"; + count.Parameters.AddWithValue("$id", setId.ToString()); + var total = checked((int)(long)count.ExecuteScalar()!); + using var command = _connection.CreateCommand(); + command.CommandText = $""" + {ReleaseSelect} WHERE x.setId=$id + ORDER BY x.releasedAt DESC, x.id DESC LIMIT $limit OFFSET $offset + """; + command.Parameters.AddWithValue("$id", setId.ToString()); + command.Parameters.AddWithValue("$limit", limit); + command.Parameters.AddWithValue("$offset", offset); + var releases = ReadReleases(command); + foreach (var release in releases) + release.Nests = ReadReleaseNests(release.Id); + return new SetReleasePage { Items = releases, Total = total, Offset = offset, Limit = limit }; + } + } + + /// The release a nest, deleted or not, came from, or null when it came from none. + public SetReleaseRecord? ReleaseForNest(Guid nestId) + { + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = "SELECT releaseId FROM set_release_nests WHERE nestId=$id"; + command.Parameters.AddWithValue("$id", nestId.ToString()); + return command.ExecuteScalar() is string releaseId ? ReadRelease(Guid.Parse(releaseId)) : null; + } + } + + // Called only under _sync with the caller's transaction. + private void WriteRelease(Guid setId, SetReleaseRecord release, SqliteTransaction transaction) + { + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """ + INSERT INTO set_releases (id, setId, setRevision, multiplier, notes, releasedAt, releasedBy) + VALUES ($id, $setId, $setRevision, $multiplier, $notes, $releasedAt, $releasedBy) + """; + command.Parameters.AddWithValue("$id", release.Id.ToString()); + command.Parameters.AddWithValue("$setId", setId.ToString()); + command.Parameters.AddWithValue("$setRevision", release.SetRevision); + command.Parameters.AddWithValue("$multiplier", release.Multiplier); + command.Parameters.AddWithValue("$notes", release.Notes); + command.Parameters.AddWithValue("$releasedAt", DateTime.Now.ToString("o")); + command.Parameters.AddWithValue("$releasedBy", release.ReleasedBy); + command.ExecuteNonQuery(); + + command.Parameters.Clear(); + command.CommandText = """ + INSERT INTO set_release_nests (releaseId, position, nestId, materialName, materialGrade, thickness, units, cutType) + VALUES ($id, $position, $nest, $materialName, $materialGrade, $thickness, $units, $cutType) + """; + command.Parameters.AddWithValue("$id", release.Id.ToString()); + var position = command.Parameters.Add("$position", SqliteType.Integer); + var nestId = command.Parameters.Add("$nest", SqliteType.Text); + var materialName = command.Parameters.Add("$materialName", SqliteType.Text); + var materialGrade = command.Parameters.Add("$materialGrade", SqliteType.Text); + var thickness = command.Parameters.Add("$thickness", SqliteType.Real); + var units = command.Parameters.Add("$units", SqliteType.Text); + var cutType = command.Parameters.Add("$cutType", SqliteType.Text); + for (var i = 0; i < release.Nests.Count; i++) + { + var nest = release.Nests[i]; + position.Value = i; + nestId.Value = nest.NestId.ToString(); + materialName.Value = nest.MaterialName; + materialGrade.Value = nest.MaterialGrade; + thickness.Value = DrawingLibraryRules.NormalizeThickness(nest.Thickness); + units.Value = nest.Units.ToString(); + cutType.Value = nest.CutType.ToString(); + command.ExecuteNonQuery(); + } + } + + // Called only under _sync. + private SetReleaseRecord? ReadRelease(Guid releaseId) + { + using var command = _connection.CreateCommand(); + command.CommandText = $"{ReleaseSelect} WHERE x.id=$id"; + command.Parameters.AddWithValue("$id", releaseId.ToString()); + var release = ReadReleases(command).FirstOrDefault(); + if (release is not null) + release.Nests = ReadReleaseNests(release.Id); + return release; + } + + private static List ReadReleases(SqliteCommand command) + { + var releases = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + releases.Add(new SetReleaseRecord + { + Id = Guid.Parse(reader.GetString(0)), + SetId = Guid.Parse(reader.GetString(1)), + SetRevision = reader.GetInt32(2), + Multiplier = reader.GetInt32(3), + Notes = reader.GetString(4), + ReleasedAt = DateTime.Parse(reader.GetString(5)), + ReleasedBy = reader.GetString(6), + SetName = reader.GetString(7), + }); + } + return releases; + } + + // Called only under _sync. Each nest joined to its current metadata, deleted or not. + private List ReadReleaseNests(Guid releaseId) + { + using var command = _connection.CreateCommand(); + command.CommandText = $""" + SELECT r.nestId, r.materialName, r.materialGrade, r.thickness, r.units, r.cutType, {JoinedNestColumns} + FROM set_release_nests r LEFT JOIN nests n ON n.id = r.nestId + WHERE r.releaseId=$id ORDER BY r.position + """; + command.Parameters.AddWithValue("$id", releaseId.ToString()); + var nests = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + nests.Add(new SetReleaseNest + { + NestId = Guid.Parse(reader.GetString(0)), + MaterialName = reader.GetString(1), + MaterialGrade = reader.GetString(2), + Thickness = reader.GetDouble(3), + Units = Enum.Parse(reader.GetString(4)), + CutType = Enum.Parse(reader.GetString(5)), + Nest = reader.IsDBNull(6) ? null : ReadRecord(reader, first: 6), + }); + } + return nests; + } +} + +/// A release post collided with an existing release; nothing was changed. +public sealed class SetReleaseConflictException(SetReleaseRecord current, string message) : Exception(message) +{ + /// The release that already holds the id or the nest. + public SetReleaseRecord Current { get; } = current; +} diff --git a/OpenNest.Server/NestDatabase.Sets.cs b/OpenNest.Server/NestDatabase.Sets.cs index a30553ad..8494c84d 100644 --- a/OpenNest.Server/NestDatabase.Sets.cs +++ b/OpenNest.Server/NestDatabase.Sets.cs @@ -39,6 +39,7 @@ public sealed partial class NestDatabase CREATE INDEX ix_drawing_sets_name ON drawing_sets(customerKey, nameKey); CREATE INDEX ix_drawing_sets_savedAt ON drawing_sets(savedAt DESC); CREATE INDEX ix_drawing_set_members_drawing ON drawing_set_members(drawingId); + {SetReleaseTables} PRAGMA user_version=7; """; diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 5a81a726..f67423c3 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -475,28 +475,29 @@ public sealed partial class NestDatabase : IDisposable } } - private static NestRecord ReadRecord(SqliteDataReader reader) + // Reads RecordColumns in order from column `first` on; a join may put other columns before them. + private static NestRecord ReadRecord(SqliteDataReader reader, int first = 0) { return new NestRecord { - Id = Guid.Parse(reader.GetString(0)), - Name = reader.GetString(1), - Customer = reader.GetString(2), - DateCreated = DateTime.Parse(reader.GetString(3)), - DateModified = DateTime.Parse(reader.GetString(4)), - Material = reader.GetString(5), - Thickness = reader.GetDouble(6), - Status = Enum.TryParse(reader.GetString(7), true, out var s) + Id = Guid.Parse(reader.GetString(first)), + Name = reader.GetString(first + 1), + Customer = reader.GetString(first + 2), + DateCreated = DateTime.Parse(reader.GetString(first + 3)), + DateModified = DateTime.Parse(reader.GetString(first + 4)), + Material = reader.GetString(first + 5), + Thickness = reader.GetDouble(first + 6), + Status = Enum.TryParse(reader.GetString(first + 7), true, out var s) ? s : NestStatus.Quote, - PlateCount = (int)reader.GetInt64(8), - PartCount = (int)reader.GetInt64(9), - Comments = reader.GetString(10), - MadeBy = reader.GetString(11), - FileSize = reader.GetInt64(12), - SavedAt = DateTime.Parse(reader.GetString(13)), - Revision = reader.GetInt32(14), - Deleted = reader.GetInt32(15) != 0, - RestoredFromRevision = reader.IsDBNull(16) ? null : reader.GetInt32(16), + PlateCount = (int)reader.GetInt64(first + 8), + PartCount = (int)reader.GetInt64(first + 9), + Comments = reader.GetString(first + 10), + MadeBy = reader.GetString(first + 11), + FileSize = reader.GetInt64(first + 12), + SavedAt = DateTime.Parse(reader.GetString(first + 13)), + Revision = reader.GetInt32(first + 14), + Deleted = reader.GetInt32(first + 15) != 0, + RestoredFromRevision = reader.IsDBNull(first + 16) ? null : reader.GetInt32(first + 16), }; } } diff --git a/docs/nest-storage.md b/docs/nest-storage.md index b729637e..528bd3a3 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -374,12 +374,48 @@ save, so a record read from the server may be sent back as it is. and the refusal of unknown, repeated or out-of-range parameters follow the drawing query. +### Set releases + +A set is never added to a nest, because a nest is one material. It is +**released**: the client makes one nest per material group with every quantity +multiplied, uploads each nest, and only then posts a **release record** naming +the set revision and the nests. A failure part-way leaves ordinary nests and no +release, and nothing is rolled back silently. + +`OpenNest.Data.SetReleaseRecord` fields: `id` (client-allocated, so an +ambiguous post can be retried), `setId` (from the route; a body value must +match it), `setRevision`, `multiplier`, `notes`, `releasedBy`, the +server-computed `releasedAt`, `setName` (the set's name at that revision) and +`nests`, in order, each `{ nestId, materialName, materialGrade, thickness, units, +cutType, nest }`. The material fields are the group the nest was made for; +`nest` is the nest's current metadata (deleted or not), joined on every read so +status and deletion are always current, and ignored on post. + +| Method | Path | Body | Response | +|---|---|---|---| +| POST | `/api/sets/{id}/releases` | JSON release with a nonempty `id` | The stored release; 404 for an unknown set; 409 with the release that already holds the id or one of the nests | +| GET | `/api/sets/{id}/releases?offset=0&limit=100` | — | Release page with nests, newest first; limit 1–200 | +| GET | `/api/nests/{id}/release` | — | The release the nest (deleted or not) came from, or 404 | + +- Releases are append-only: there is no edit or delete. A mistaken release is + undone by deleting its nests, which then read as `deleted`. +- `setRevision` must be a revision of the set but need not be the current one, + and the set may be archived: the nests already exist by the time the release + is posted, so it is never refused for a state the operator cannot fix. +- Each nest belongs to at most one release (a unique index enforces it). +- Validation (400, no state change): `multiplier` 1–1000; notes at most 4000 + and releasedBy at most 200 characters; `nests` present, 1–500 entries, no + nest twice, each an existing, not deleted nest with material name and grade + at most 200 characters, `thickness` finite and at least 0 (stored rounded to 4 + decimals), and `units` and `cutType` single enum names. + Schema v7 adds `drawing_set_revisions` (every revision's details and archived flag), `drawing_set_members` (each revision's members by position, unique per -drawing) and `drawing_sets` (each set's current revision). Upgrading from v6 -only creates these tables; every other table is untouched. A server older than +drawing), `drawing_sets` (each set's current revision), `set_releases` and +`set_release_nests`. Upgrading from v6 only creates these tables; every other +table is untouched. A server older than v7 refuses to open a v7 database, so rolling back needs the pre-upgrade backup. -The stopped-volume `tar` backup includes the set tables; `nest_manifest.py` and +The stopped-volume `tar` backup includes the set and release tables; `nest_manifest.py` and `restore_check` do not verify them yet. ## Storage