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