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

+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;
}
}