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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 13:27:27 -04:00
1 parent be057bf337
commit 44666b289d
14 files changed
+999 -46

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
using OpenNest;
namespace OpenNest.Data;
/// <summary>One nest of a release and the material group it was made for.</summary>
public sealed class SetReleaseNest
{
public Guid NestId { get; set; }
public string MaterialName { get; set; } = "";
public string MaterialGrade { get; set; } = "";
/// <summary>Thickness in <see cref="Units"/>, stored rounded to four decimals.</summary>
public double Thickness { get; set; }
public Units Units { get; set; } = Units.Inches;
public CutType CutType { get; set; } = CutType.Laser;
/// <summary>
/// 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.
/// </summary>
public NestRecord? Nest { get; set; }
}
+14
View File
@@ -0,0 +1,14 @@
namespace OpenNest.Data;
/// <summary>One bounded page of set releases, newest first, each with its nests.</summary>
public sealed class SetReleasePage
{
public IReadOnlyList<SetReleaseRecord> Items { get; init; } = Array.Empty<SetReleaseRecord>();
/// <summary>Number of releases of the set across all pages.</summary>
public int Total { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
+34
View File
@@ -0,0 +1,34 @@
namespace OpenNest.Data;
/// <summary>
/// One release of a drawing set revision: the nests made from it, one per material group,
/// with every member quantity multiplied by <see cref="Multiplier"/>. Releases are
/// append-only; a mistaken one is undone by deleting its nests, which then read as deleted.
/// </summary>
public sealed class SetReleaseRecord
{
/// <summary>Allocated by the client, so an ambiguous post can be retried without a duplicate.</summary>
public Guid Id { get; set; }
/// <summary>The set released. Taken from the route on post.</summary>
public Guid SetId { get; set; }
/// <summary>The exact set revision released; it need not be the current one.</summary>
public int SetRevision { get; set; }
/// <summary>The set's name at <see cref="SetRevision"/>. Server-joined; ignored on post.</summary>
public string SetName { get; set; } = "";
public int Multiplier { get; set; } = 1;
public string Notes { get; set; } = "";
/// <summary>When the server stored the release. Server-computed.</summary>
public DateTime ReleasedAt { get; set; }
/// <summary>Who released the set, as reported by the client.</summary>
public string ReleasedBy { get; set; } = "";
/// <summary>The nests created by the release, in the order posted.</summary>
public IReadOnlyList<SetReleaseNest> Nests { get; set; } = Array.Empty<SetReleaseNest>();
}
+57
View File
@@ -0,0 +1,57 @@
using OpenNest;
namespace OpenNest.Data;
/// <summary>
/// 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.
/// </summary>
public static class SetReleaseRules
{
public const int MaxMultiplier = 1000;
/// <summary>One nest per material group, so never more nests than a set has members.</summary>
public const int MaxNests = DrawingSetRules.MaxMembers;
/// <summary>The first rule <paramref name="release"/> breaks, or null when it may be posted.</summary>
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<Guid>();
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;
}
}
@@ -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;
""");
@@ -43,7 +43,7 @@ public sealed class DrawingSetDatabaseTests
Assert.Throws<SqliteException>(() => 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
}
/// <summary>
/// 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.
/// </summary>
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;
}
@@ -50,4 +50,40 @@ internal static class DrawingSetFixtures
public static IEnumerable<(Guid, int)> MembersOf(DrawingSetRecord record) =>
record.Members.Select(m => (m.DrawingId, m.Quantity));
/// <summary>Stores a draft nest directly and returns its id.</summary>
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<Guid> 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;
}
/// <summary>A release of <paramref name="setRevision"/> making one nest per id, each in its own material.</summary>
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(),
};
}
+368
View File
@@ -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<Guid>();
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<ArgumentOutOfRangeException>(() => 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<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(2, free))).Message);
Assert.Contains("does not exist", Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, Guid.NewGuid()))).Message);
Assert.Contains("is deleted", Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, deleted))).Message);
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, free)));
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1)));
var noId = Release(1, free);
noId.Id = Guid.Empty;
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, noId));
var tooMany = Release(1, free);
tooMany.Multiplier = SetReleaseRules.MaxMultiplier + 1;
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, tooMany));
var claimed = Assert.Throws<SetReleaseConflictException>(() => 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<SetReleaseConflictException>(() => 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<Exception?>(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<string>());
Assert.Equal("Synthetic kit", json["setName"]!.GetValue<string>());
Assert.Equal("millimeters", json["nests"]![1]!["units"]!.GetValue<string>());
Assert.Equal("plasma", json["nests"]![1]!["cutType"]!.GetValue<string>());
Assert.Equal("draft", json["nests"]![0]!["nest"]!["status"]!.GetValue<string>());
var page = (await client.GetFromJsonAsync<SetReleasePage>($"/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<SetReleaseRecord>($"/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<SetReleaseRecord>(Options))!.Id);
}
Assert.Equal(1, (await client.GetFromJsonAsync<SetReleasePage>($"/api/sets/{set.Id}/releases", Options))!.Total);
}
public static TheoryData<string, string> 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<string, string>
{
{ "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<SetReleasePage>($"/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();
}
}
+97 -12
View File
@@ -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<JsonObject>())
{
foreach (var key in member.Select(p => p.Key)
.Where(k => string.Equals(k, "drawing", StringComparison.OrdinalIgnoreCase)).ToList())
member.Remove(key);
}
}
record = root.Deserialize<DrawingSetRecord>(SetOptions) ?? record;
}
catch (JsonException)
@@ -137,4 +135,91 @@ internal static class DrawingSetRequest
error = DrawingSetRules.GetError(record) ?? "";
return error.Length == 0;
}
/// <summary>
/// Reads and validates one release JSON object. <c>nests</c> must be present, each
/// nest's <c>units</c> and <c>cutType</c> must be single names, and each nest's joined
/// <c>nest</c> is ignored.
/// </summary>
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>(units))
{
error = $"nests[{i}].units must be a supported unit name.";
return false;
}
if (Property(nest, "cutType") is { } cutType && !IsName<CutType>(cutType))
{
error = $"nests[{i}].cutType must be Laser, Waterjet or Plasma.";
return false;
}
}
}
release = root.Deserialize<SetReleaseRecord>(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<TEnum>(JsonNode value)
where TEnum : struct, Enum =>
value is JsonValue text && text.TryGetValue<string>(out var name) && DrawingLibraryRequest.TryEnumName<TEnum>(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<JsonObject>())
{
foreach (var key in item.Select(p => p.Key)
.Where(k => string.Equals(k, joined, StringComparison.OrdinalIgnoreCase)).ToList())
item.Remove(key);
}
}
return true;
}
}
+55 -10
View File
@@ -4,9 +4,10 @@ using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// <c>/api/sets</c>: 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).
/// <c>/api/sets</c>: 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).
/// </summary>
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);
}
}
+246
View File
@@ -0,0 +1,246 @@
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// Set release storage. <c>set_releases</c> records which set revision was released and
/// how many times over; <c>set_release_nests</c> 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.
/// </summary>
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()));
/// <summary>
/// Records a release of <paramref name="setId"/>. 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.
/// </summary>
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.");
}
}
/// <summary>One page of the set's releases, newest first, or null for an unknown set.</summary>
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 };
}
}
/// <summary>The release a nest, deleted or not, came from, or null when it came from none.</summary>
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<SetReleaseRecord> ReadReleases(SqliteCommand command)
{
var releases = new List<SetReleaseRecord>();
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<SetReleaseNest> 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<SetReleaseNest>();
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<Units>(reader.GetString(4)),
CutType = Enum.Parse<CutType>(reader.GetString(5)),
Nest = reader.IsDBNull(6) ? null : ReadRecord(reader, first: 6),
});
}
return nests;
}
}
/// <summary>A release post collided with an existing release; nothing was changed.</summary>
public sealed class SetReleaseConflictException(SetReleaseRecord current, string message) : Exception(message)
{
/// <summary>The release that already holds the id or the nest.</summary>
public SetReleaseRecord Current { get; } = current;
}
+1
View File
@@ -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;
""";
+19 -18
View File
@@ -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<NestStatus>(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<NestStatus>(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),
};
}
}
+39 -3
View File
@@ -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