diff --git a/OpenNest.Data/DrawingSetMember.cs b/OpenNest.Data/DrawingSetMember.cs
new file mode 100644
index 00000000..1f37d833
--- /dev/null
+++ b/OpenNest.Data/DrawingSetMember.cs
@@ -0,0 +1,15 @@
+namespace OpenNest.Data;
+
+/// One drawing of a set and how many of it the set needs.
+public sealed class DrawingSetMember
+{
+ public Guid DrawingId { get; set; }
+
+ public int Quantity { get; set; } = 1;
+
+ ///
+ /// The drawing's current record, archived or not, joined by the server when it reads
+ /// a set. Ignored on save.
+ ///
+ public DrawingLibraryRecord? Drawing { get; set; }
+}
diff --git a/OpenNest.Data/DrawingSetPage.cs b/OpenNest.Data/DrawingSetPage.cs
new file mode 100644
index 00000000..038c3abb
--- /dev/null
+++ b/OpenNest.Data/DrawingSetPage.cs
@@ -0,0 +1,15 @@
+namespace OpenNest.Data;
+
+/// One bounded page of drawing set records, without their members.
+public sealed class DrawingSetPage
+{
+ /// At most records, in the requested order.
+ public IReadOnlyList Items { get; init; } = Array.Empty();
+
+ /// Number of records matching the request across all pages.
+ public int Total { get; init; }
+
+ public int Offset { get; init; }
+
+ public int Limit { get; init; }
+}
diff --git a/OpenNest.Data/DrawingSetQuery.cs b/OpenNest.Data/DrawingSetQuery.cs
new file mode 100644
index 00000000..a9162e10
--- /dev/null
+++ b/OpenNest.Data/DrawingSetQuery.cs
@@ -0,0 +1,48 @@
+namespace OpenNest.Data;
+
+///
+/// One bounded page request for browsing drawing sets (GET /api/sets/query).
+/// The server filters and pages in SQL; the client and server share these bounds.
+///
+public sealed class DrawingSetQuery
+{
+ public const int DefaultLimit = 100;
+ public const int MaxLimit = 500;
+ public const int MaxSearchLength = 200;
+
+ ///
+ /// Case-insensitive (ASCII) substring of name, customer or notes. Blank means no filter.
+ ///
+ public string Search { get; init; } = "";
+
+ /// Exact customer, compared by . Blank means any.
+ public string Customer { get; init; } = "";
+
+ public bool IncludeArchived { get; init; }
+
+ /// Column to order by; ties are broken by id in the same direction.
+ public DrawingSetSortField Sort { get; init; } = DrawingSetSortField.SavedAt;
+
+ /// Largest/newest/Z first when true (the default, newest saved first).
+ public bool Descending { get; init; } = true;
+
+ public int Offset { get; init; }
+
+ public int Limit { get; init; } = DefaultLimit;
+
+ /// The search text as matched: trimmed, never null.
+ public string NormalizedSearch => (Search ?? "").Trim();
+
+ /// The first violated bound, or null when the query may be sent.
+ public string? GetValidationError()
+ {
+ if (!Enum.IsDefined(Sort))
+ return "sort must be a supported column.";
+ if (Offset < 0)
+ return "offset must be zero or greater.";
+ if (Limit < 1 || Limit > MaxLimit)
+ return $"limit must be between 1 and {MaxLimit}.";
+ return DrawingLibraryRules.TextError("search", NormalizedSearch, MaxSearchLength)
+ ?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength);
+ }
+}
diff --git a/OpenNest.Data/DrawingSetRecord.cs b/OpenNest.Data/DrawingSetRecord.cs
new file mode 100644
index 00000000..fca2c7cc
--- /dev/null
+++ b/OpenNest.Data/DrawingSetRecord.cs
@@ -0,0 +1,39 @@
+namespace OpenNest.Data;
+
+///
+/// One revision of a drawing set: a named list of library drawings with per-member
+/// quantities (a kit, an assembly, a recurring order). Members reference drawings by id;
+/// the set always means each drawing's current revision.
+///
+public sealed class DrawingSetRecord
+{
+ /// Allocated by the client before the first save; kept by every revision.
+ public Guid Id { get; set; }
+
+ /// Immutable revision number, starting at 1. Ignored on save.
+ public int Revision { get; set; }
+
+ public string Name { get; set; } = "";
+
+ public string Customer { get; set; } = "";
+
+ public string Notes { get; set; } = "";
+
+ /// Hidden from browsing by default; still readable by id.
+ public bool Archived { get; set; }
+
+ /// When the server stored this revision. Server-computed.
+ public DateTime SavedAt { get; set; }
+
+ /// Who saved this revision, as reported by the client.
+ public string SavedBy { get; set; } = "";
+
+ /// Number of members in this revision. Server-computed.
+ public int MemberCount { get; set; }
+
+ ///
+ /// The members in order, one per drawing. Empty on query and history pages, which
+ /// carry only.
+ ///
+ public IReadOnlyList Members { get; set; } = Array.Empty();
+}
diff --git a/OpenNest.Data/DrawingSetRules.cs b/OpenNest.Data/DrawingSetRules.cs
new file mode 100644
index 00000000..88e38bf8
--- /dev/null
+++ b/OpenNest.Data/DrawingSetRules.cs
@@ -0,0 +1,51 @@
+namespace OpenNest.Data;
+
+///
+/// Bounds shared by the drawing set client and server. The client checks them before
+/// sending; the server refuses anything outside them with no state change. Whether each
+/// member's drawing exists is checked by the server.
+///
+public static class DrawingSetRules
+{
+ public const int MaxMembers = 500;
+
+ public const int MaxQuantity = 100_000;
+
+ /// Largest accepted set JSON body (1 MiB): room for members echoed with their drawings.
+ public const int MaxBodyBytes = 1024 * 1024;
+
+ /// The first rule breaks, or null when it may be saved.
+ public static string? GetError(DrawingSetRecord? record)
+ {
+ if (record is null)
+ return "Missing set.";
+ if (string.IsNullOrWhiteSpace(record.Name))
+ return "name is required.";
+ var error = DrawingLibraryRules.TextError("name", record.Name, DrawingLibraryRules.MaxTextLength)
+ ?? DrawingLibraryRules.TextError("customer", record.Customer, DrawingLibraryRules.MaxTextLength)
+ ?? DrawingLibraryRules.TextError("savedBy", record.SavedBy, DrawingLibraryRules.MaxTextLength)
+ ?? DrawingLibraryRules.TextError("notes", record.Notes, DrawingLibraryRules.MaxNotesLength);
+ if (error is not null)
+ return error;
+ if (record.Members is null)
+ return "members is required.";
+ if (record.Members.Count > MaxMembers)
+ return $"A set may have at most {MaxMembers} members.";
+
+ var seen = new HashSet();
+ for (var i = 0; i < record.Members.Count; i++)
+ {
+ var member = record.Members[i];
+ if (member is null)
+ return $"members[{i}] is required.";
+ if (member.DrawingId == Guid.Empty)
+ return $"members[{i}].drawingId is required.";
+ if (member.Quantity < 1 || member.Quantity > MaxQuantity)
+ return $"members[{i}].quantity must be between 1 and {MaxQuantity}.";
+ if (!seen.Add(member.DrawingId))
+ return $"members[{i}] repeats drawing {member.DrawingId}: use one member with a larger quantity.";
+ }
+
+ return null;
+ }
+}
diff --git a/OpenNest.Data/DrawingSetSortField.cs b/OpenNest.Data/DrawingSetSortField.cs
new file mode 100644
index 00000000..7a894239
--- /dev/null
+++ b/OpenNest.Data/DrawingSetSortField.cs
@@ -0,0 +1,10 @@
+namespace OpenNest.Data;
+
+/// Columns a drawing set query may order by (camelCase on the wire).
+public enum DrawingSetSortField
+{
+ SavedAt,
+ Name,
+ Customer,
+ Revision,
+}
diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
index 944dd18b..4a9e9139 100644
--- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
+++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
@@ -21,7 +21,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
- Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -42,7 +42,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
- Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -79,7 +79,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
- Execute(factory.DatabasePath, "PRAGMA user_version=7");
+ Execute(factory.DatabasePath, "PRAGMA user_version=8");
Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -220,7 +220,7 @@ public sealed class DrawingLibraryDatabaseTests
}
}
- /// A real schema-v3 file: the drawing tables are additive, so dropping them restores v3 exactly.
+ /// A real schema-v3 file: the drawing and set tables are additive, so dropping them restores v3 exactly.
private static void CreateVersion3Database(string path)
{
using (var database = new NestDatabase(path, pooling: false))
@@ -235,7 +235,10 @@ public sealed class DrawingLibraryDatabaseTests
database.SetDeleted(a.Id, false, 3);
}
- Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3;");
+ Execute(path, """
+ 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;
+ """);
}
private static readonly byte[] V4PackageA = Package("v4 original");
diff --git a/OpenNest.Server.Tests/DrawingSetApiTests.cs b/OpenNest.Server.Tests/DrawingSetApiTests.cs
new file mode 100644
index 00000000..bedb4954
--- /dev/null
+++ b/OpenNest.Server.Tests/DrawingSetApiTests.cs
@@ -0,0 +1,337 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using OpenNest.Data;
+using static OpenNest.Server.Tests.DrawingLibraryFixtures;
+using static OpenNest.Server.Tests.DrawingSetFixtures;
+
+namespace OpenNest.Server.Tests;
+
+public sealed class DrawingSetApiTests
+{
+ [Fact]
+ public async Task Create_StoresRevisionOneWithMembersJoinedToTheirDrawings()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var a = await AddDrawing(client, "Synthetic A");
+ var b = await AddDrawing(client, "Synthetic B", r => r.CutType = CutType.Waterjet);
+ var set = Set(members: [(a, 4), (b, 1)]);
+ set.Revision = 9;
+ set.MemberCount = 99;
+
+ var saved = await Create(client, set);
+
+ Assert.Equal((set.Id, 1, 2, false), (saved.Id, saved.Revision, saved.MemberCount, saved.Archived));
+ Assert.Equal(("Synthetic kit", "Synthetic customer", "Synthetic set notes", "Synthetic operator"),
+ (saved.Name, saved.Customer, saved.Notes, saved.SavedBy));
+ Assert.NotEqual(default, saved.SavedAt);
+ Assert.Equal(new[] { (a, 4), (b, 1) }, MembersOf(saved));
+ Assert.Equal(new[] { "Synthetic A", "Synthetic B" }, saved.Members.Select(m => m.Drawing!.Name));
+ var fetched = await client.GetFromJsonAsync($"/api/sets/{set.Id}", Options);
+ Assert.Equal(JsonSerializer.Serialize(saved), JsonSerializer.Serialize(fetched));
+
+ var json = JsonNode.Parse(await client.GetStringAsync($"/api/sets/{set.Id}"))!;
+ var joined = json["members"]![1]!["drawing"]!;
+ Assert.Equal(b.ToString(), json["members"]![1]!["drawingId"]!.GetValue());
+ Assert.Equal("waterjet", joined["cutType"]!.GetValue());
+ Assert.Equal("inches", joined["units"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Create_ExistingId_ConflictsWithTheCurrentRecord()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var set = Set();
+ await Create(client, set);
+
+ using var response = await client.PostAsync("/api/sets", Json(Set("Second writer", set.Id)));
+
+ Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
+ var current = await Read(response);
+ Assert.Equal(("Synthetic kit", 1), (current.Name, current.Revision));
+ }
+
+ public static TheoryData RejectedSets()
+ {
+ var drawing = Guid.Parse("00000000-0000-0000-0000-0000000000d1");
+ var valid = JsonSerializer.Serialize(Set(members: (drawing, 2)), Options);
+ string With(string property, JsonNode? value)
+ {
+ var node = JsonNode.Parse(valid)!.AsObject();
+ node[property] = value;
+ return node.ToJsonString();
+ }
+ string Without(string property)
+ {
+ var node = JsonNode.Parse(valid)!.AsObject();
+ node.Remove(property);
+ return node.ToJsonString();
+ }
+ JsonArray Members(params (Guid Drawing, object? Quantity)[] members) =>
+ new(members.Select(m => (JsonNode)new JsonObject { ["drawingId"] = m.Drawing.ToString(), ["quantity"] = JsonValue.Create(m.Quantity) }).ToArray());
+
+ return new TheoryData
+ {
+ { "empty id", With("id", Guid.Empty.ToString()) },
+ { "blank name", With("name", " ") },
+ { "null name", With("name", null) },
+ { "long name", With("name", new string('n', 201)) },
+ { "NUL customer", With("customer", "a\0b") },
+ { "long notes", With("notes", new string('x', 4001)) },
+ { "missing members", Without("members") },
+ { "null members", With("members", null) },
+ { "members not an array", With("members", "a") },
+ { "null member", With("members", new JsonArray((JsonNode?)null)) },
+ { "empty drawing id", With("members", Members((Guid.Empty, 1))) },
+ { "zero quantity", With("members", Members((drawing, 0))) },
+ { "quantity over the limit", With("members", Members((drawing, 100_001))) },
+ { "fractional quantity", With("members", Members((drawing, 1.5))) },
+ { "repeated drawing", With("members", Members((drawing, 1), (drawing, 2))) },
+ { "unknown drawing", With("members", Members((Guid.NewGuid(), 1))) },
+ { "too many members", With("members", Members(Enumerable.Range(0, 501).Select(_ => (drawing, (object?)1)).ToArray())) },
+ { "not an object", "[1]" },
+ { "malformed", "{" },
+ { "empty", "" },
+ };
+ }
+
+ [Theory]
+ [MemberData(nameof(RejectedSets))]
+ public async Task Create_InvalidSet_IsRejectedWithNoState(string row, string json)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ // The one drawing the valid members name.
+ await AddDrawing(client, "Synthetic A", r => r.Id = Guid.Parse("00000000-0000-0000-0000-0000000000d1"));
+
+ using var response = await client.PostAsync("/api/sets", Json(json));
+
+ Assert.True(response.StatusCode == HttpStatusCode.BadRequest, row);
+ await AssertNoSets(client);
+ }
+
+ [Fact]
+ public async Task Create_WrongContentTypeOrOversizedBody_IsRejected()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+
+ using var text = await client.PostAsync("/api/sets", new StringContent(JsonSerializer.Serialize(Set(), Options)));
+ var padded = Set();
+ padded.Notes = new string(' ', DrawingSetRules.MaxBodyBytes);
+ using var oversized = await client.PostAsync("/api/sets", Json(padded));
+
+ Assert.Equal(HttpStatusCode.BadRequest, text.StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, oversized.StatusCode);
+ Assert.Contains("too large", await oversized.Content.ReadAsStringAsync());
+ await AssertNoSets(client);
+ }
+
+ [Fact]
+ public async Task Update_FollowsTheIfMatchContract()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var drawing = await AddDrawing(client);
+ var set = await Create(client, Set(members: (drawing, 1)));
+ var url = $"/api/sets/{set.Id}";
+
+ async Task Put(string? ifMatch, DrawingSetRecord body, string target)
+ {
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, target, ifMatch, Json(body)));
+ return response.StatusCode;
+ }
+
+ Assert.Equal((HttpStatusCode)428, await Put(null, Set("Edit", set.Id), url));
+ Assert.Equal(HttpStatusCode.BadRequest, await Put("1", Set("Edit", set.Id), url));
+ Assert.Equal(HttpStatusCode.BadRequest, await Put("\"1\"", Set("Edit", Guid.NewGuid()), url));
+ Assert.Equal(HttpStatusCode.NotFound, await Put("\"1\"", Set("Edit", Guid.Empty), $"/api/sets/{Guid.NewGuid()}"));
+ using (var stale = await client.SendAsync(Guarded(HttpMethod.Put, url, "\"2\"", Json(Set("Edit", set.Id)))))
+ {
+ Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
+ var current = await Read(stale);
+ Assert.Equal((1, "Synthetic kit"), (current.Revision, current.Name));
+ Assert.Single(current.Members);
+ }
+
+ // An id-less body updates the route's set.
+ var edit = Set("Edited kit", Guid.Empty, (drawing, 7));
+ using var saved = await client.SendAsync(Guarded(HttpMethod.Put, url, "\"1\"", Json(edit)));
+ Assert.Equal(HttpStatusCode.OK, saved.StatusCode);
+ var updated = await Read(saved);
+ Assert.Equal((2, "Edited kit"), (updated.Revision, updated.Name));
+ Assert.Equal(new[] { (drawing, 7) }, MembersOf(updated));
+ }
+
+ [Fact]
+ public async Task Update_EchoingAReadRecord_IsANoOp()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var drawing = await AddDrawing(client);
+ var created = await Create(client, Set(members: (drawing, 3)));
+ var set = (await client.GetFromJsonAsync($"/api/sets/{created.Id}", Options))!;
+ Assert.NotNull(set.Members[0].Drawing);
+
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/sets/{set.Id}", "\"1\"", Json(set)));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(1, (await Read(response)).Revision);
+ var history = await client.GetFromJsonAsync($"/api/sets/{set.Id}/revisions", Options);
+ Assert.Equal(1, history!.Total);
+ }
+
+ [Fact]
+ public async Task ArchiveAndRestore_HideFromBrowsingAndGuardWrites()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var set = await Create(client, Set());
+ var url = $"/api/sets/{set.Id}";
+
+ using (var missing = await client.SendAsync(Guarded(HttpMethod.Post, url + "/archive", null)))
+ Assert.Equal((HttpStatusCode)428, missing.StatusCode);
+ using (var archived = await client.SendAsync(Guarded(HttpMethod.Post, url + "/archive", "\"1\"")))
+ Assert.True((await Read(archived)).Archived);
+ using (var again = await client.SendAsync(Guarded(HttpMethod.Post, url + "/archive", "\"2\"")))
+ Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
+ using (var write = await client.SendAsync(Guarded(HttpMethod.Put, url, "\"2\"", Json(Set("Edit", set.Id)))))
+ Assert.Equal(HttpStatusCode.Conflict, write.StatusCode);
+
+ Assert.Empty((await client.GetFromJsonAsync("/api/sets/query", Options))!.Items);
+ Assert.Single((await client.GetFromJsonAsync("/api/sets/query?includeArchived=true", Options))!.Items);
+ Assert.True((await client.GetFromJsonAsync(url, Options))!.Archived);
+
+ using (var restored = await client.SendAsync(Guarded(HttpMethod.Post, url + "/restore", "\"2\"")))
+ {
+ var record = await Read(restored);
+ Assert.Equal((3, false), (record.Revision, record.Archived));
+ }
+ using (var unknown = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/sets/{Guid.NewGuid()}/restore", "\"1\"")))
+ Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
+ }
+
+ [Fact]
+ public async Task Query_FiltersSortsAndPagesInSql()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var drawing = await AddDrawing(client);
+ await Create(client, Set("Gamma frame", members: (drawing, 1)));
+ var alpha = Set("alpha kit");
+ alpha.Customer = "Other Customer";
+ await Create(client, alpha);
+ var beta = Set("Beta kit");
+ beta.Notes = "Contains 100% literal_text";
+ await Create(client, beta);
+
+ async Task Names(string query) =>
+ (await client.GetFromJsonAsync("/api/sets/query" + query, Options))!.Items.Select(s => s.Name).ToArray();
+
+ Assert.Equal(new[] { "alpha kit", "Beta kit", "Gamma frame" }, await Names("?sort=name&order=asc"));
+ Assert.Equal(new[] { "alpha kit", "Beta kit" }, await Names("?search=KIT&sort=name&order=asc"));
+ Assert.Equal(new[] { "Beta kit" }, await Names("?search=100%25"));
+ Assert.Equal(new[] { "Beta kit" }, await Names("?search=l_t"));
+ Assert.Equal(new[] { "alpha kit" }, await Names("?customer=%20other%20customer"));
+ Assert.Equal(new[] { "Beta kit" }, await Names("?sort=name&order=asc&offset=1&limit=1"));
+ var page = await client.GetFromJsonAsync("/api/sets/query?limit=1", Options);
+ Assert.Equal((3, 1), (page!.Total, page.Items.Count));
+ Assert.Empty(page.Items[0].Members);
+ var gamma = (await client.GetFromJsonAsync("/api/sets/query?search=gamma", Options))!.Items.Single();
+ Assert.Equal(1, gamma.MemberCount);
+ }
+
+ [Theory]
+ [InlineData("?bogus=1")]
+ [InlineData("?search=a&search=b")]
+ [InlineData("?sort=memberCount")]
+ [InlineData("?sort=1")]
+ [InlineData("?order=up")]
+ [InlineData("?includeArchived=yes")]
+ [InlineData("?offset=-1")]
+ [InlineData("?limit=0")]
+ [InlineData("?limit=501")]
+ [InlineData("?materialName=Steel")]
+ public async Task Query_RefusesUnknownRepeatedOrOutOfRangeParameters(string query)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+
+ using var response = await client.GetAsync("/api/sets/query" + query);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Revisions_PageWithoutMembersAndReadOneWithItsMembers()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var a = await AddDrawing(client, "Synthetic A");
+ var b = await AddDrawing(client, "Synthetic B");
+ var set = await Create(client, Set(members: [(a, 1), (b, 2)]));
+ using (await client.SendAsync(Guarded(HttpMethod.Put, $"/api/sets/{set.Id}", "\"1\"", Json(Set("Edited", set.Id, (b, 9)))))) { }
+
+ var history = (await client.GetFromJsonAsync($"/api/sets/{set.Id}/revisions?limit=1&offset=1", Options))!;
+ Assert.Equal((2, 1, 2), (history.Total, history.Items.Single().Revision, history.Items.Single().MemberCount));
+ Assert.Empty(history.Items.Single().Members);
+
+ var first = (await client.GetFromJsonAsync($"/api/sets/{set.Id}/revisions/1", Options))!;
+ Assert.Equal("Synthetic kit", first.Name);
+ Assert.Equal(new[] { (a, 1), (b, 2) }, MembersOf(first));
+ Assert.Equal("Synthetic B", first.Members[1].Drawing!.Name);
+
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{set.Id}/revisions/3")).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/revisions/0")).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/revisions?limit=201")).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/revisions?page=1")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{Guid.NewGuid()}/revisions")).StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{Guid.NewGuid()}")).StatusCode);
+ }
+
+ [Fact]
+ public async Task UsedIn_ListsSetsWhoseCurrentRevisionContainsTheDrawing()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var a = await AddDrawing(client, "Synthetic A");
+ var b = await AddDrawing(client, "Synthetic B");
+ await Create(client, Set("Kit one", members: (a, 1)));
+ var two = await Create(client, Set("Kit two", members: [(a, 1), (b, 1)]));
+ using (await client.SendAsync(Guarded(HttpMethod.Put, $"/api/sets/{two.Id}", "\"1\"", Json(Set("Kit two", two.Id, (b, 1)))))) { }
+
+ var usedA = (await client.GetFromJsonAsync($"/api/drawings/{a}/sets", Options))!;
+ var usedB = (await client.GetFromJsonAsync($"/api/drawings/{b}/sets", Options))!;
+
+ Assert.Equal(new[] { "Kit one" }, usedA.Items.Select(s => s.Name));
+ Assert.Equal(new[] { "Kit two" }, usedB.Items.Select(s => s.Name));
+ Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/drawings/{Guid.NewGuid()}/sets")).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/drawings/{a}/sets?limit=0")).StatusCode);
+ }
+
+ [Fact]
+ public async Task ArchivedDrawing_StaysListedWithItsFlagAndLatestRevision()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var drawing = await AddDrawing(client);
+ var set = await Create(client, Set(members: (drawing, 2)));
+ using (await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{drawing}/archive", "\"1\""))) { }
+
+ var read = (await client.GetFromJsonAsync($"/api/sets/{set.Id}", Options))!;
+
+ var member = read.Members.Single();
+ Assert.Equal((drawing, 2, true, 2), (member.DrawingId, member.Quantity, member.Drawing!.Archived, member.Drawing.Revision));
+ // An archived drawing may still be added to a set.
+ Assert.Equal(1, (await Create(client, Set("Another", members: (drawing, 1)))).MemberCount);
+ }
+
+ private static async Task AssertNoSets(HttpClient client)
+ {
+ var page = await client.GetFromJsonAsync("/api/sets/query?includeArchived=true", Options);
+ Assert.Equal(0, page!.Total);
+ }
+}
diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
new file mode 100644
index 00000000..b7d24261
--- /dev/null
+++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
@@ -0,0 +1,365 @@
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+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 DrawingSetDatabaseTests
+{
+ [Fact]
+ public void V6Upgrade_AddsSetTablesAndKeepsEveryOtherRowAndIsIdempotent()
+ {
+ using var factory = new ServerFactory();
+ var drawing = CreateVersion6Database(factory.DatabasePath);
+ var before = Digest(factory.DatabasePath);
+
+ for (var open = 0; open < 2; open++)
+ {
+ using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
+ Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
+ Assert.Equal(before, Digest(factory.DatabasePath));
+ Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ }
+
+ using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
+ var set = Set(members: (drawing, 2));
+ Assert.Equal(1, upgraded.InsertDrawingSet(set.Id, set).Revision);
+ Assert.Equal(drawing, upgraded.GetDrawingSet(set.Id)!.Members.Single().Drawing!.Id);
+ }
+
+ [Fact]
+ public void FailedSetMigration_RollsBackAndCanRestart()
+ {
+ using var factory = new ServerFactory();
+ CreateVersion6Database(factory.DatabasePath);
+ var before = Digest(factory.DatabasePath);
+ // A table holding an index's name makes the set schema fail part-way through.
+ Execute(factory.DatabasePath, "CREATE TABLE ix_drawing_set_members_drawing (fixture TEXT)");
+
+ 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(before, Digest(factory.DatabasePath));
+ Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ Assert.Equal(7L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ }
+
+ [Fact]
+ public void EveryChange_AppendsAnImmutableRevisionWithItsOwnMembers()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var a = AddDrawing(database, "Synthetic A");
+ var b = AddDrawing(database, "Synthetic B");
+ var set = Set(members: [(a, 2), (b, 1)]);
+
+ database.InsertDrawingSet(set.Id, set);
+ var edit = Set("Renamed kit", set.Id, (b, 5));
+ Assert.Equal(2, database.UpdateDrawingSet(set.Id, edit, 1)!.Revision);
+ Assert.True(database.SetDrawingSetArchived(set.Id, true, 2)!.Archived);
+ var restored = database.SetDrawingSetArchived(set.Id, false, 3)!;
+
+ Assert.Equal((4, "Renamed kit", false), (restored.Revision, restored.Name, restored.Archived));
+ Assert.Equal(new[] { (b, 5) }, MembersOf(restored));
+ var history = database.DrawingSetHistory(set.Id, 0, 100)!;
+ Assert.Equal(new[] { 4, 3, 2, 1 }, history.Items.Select(r => r.Revision));
+ Assert.Equal(new[] { false, true, false, false }, history.Items.Select(r => r.Archived));
+ Assert.Equal(new[] { 1, 1, 1, 2 }, history.Items.Select(r => r.MemberCount));
+ Assert.All(history.Items, r => Assert.Empty(r.Members));
+ var first = database.GetDrawingSetRevision(set.Id, 1)!;
+ Assert.Equal(("Synthetic kit", 2), (first.Name, first.MemberCount));
+ Assert.Equal(new[] { (a, 2), (b, 1) }, MembersOf(first));
+ Assert.Null(database.GetDrawingSetRevision(set.Id, 5));
+ }
+
+ [Fact]
+ public void IdenticalSave_IsANoOpButMemberOrderIsAChange()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var a = AddDrawing(database, "Synthetic A");
+ var b = AddDrawing(database, "Synthetic B");
+ var set = Set(members: [(a, 2), (b, 1)]);
+ database.InsertDrawingSet(set.Id, set);
+
+ set.SavedBy = "Another operator";
+ Assert.Equal(1, database.UpdateDrawingSet(set.Id, set, 1)!.Revision);
+ Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_set_revisions"));
+
+ var reordered = Set(members: [(b, 1), (a, 2)]);
+ var saved = database.UpdateDrawingSet(set.Id, reordered, 1)!;
+ Assert.Equal(2, saved.Revision);
+ Assert.Equal(new[] { (b, 1), (a, 2) }, MembersOf(saved));
+ }
+
+ [Fact]
+ public void Members_JoinTheDrawingsCurrentRevisionIncludingArchived()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var drawing = AddDrawing(database);
+ var set = Set(members: (drawing, 3));
+ database.InsertDrawingSet(set.Id, set);
+
+ var edit = database.GetDrawing(drawing)!;
+ edit.MaterialName = "Synthetic Aluminum";
+ database.UpdateDrawing(drawing, edit, Package("revised"), 1);
+ database.SetDrawingArchived(drawing, true, 2);
+
+ foreach (var read in new[] { database.GetDrawingSet(set.Id)!, database.GetDrawingSetRevision(set.Id, 1)! })
+ {
+ var joined = read.Members.Single().Drawing!;
+ Assert.Equal((3, "Synthetic Aluminum", true), (joined.Revision, joined.MaterialName, joined.Archived));
+ Assert.Equal(database.GetDrawing(drawing)!.PackageSha256, joined.PackageSha256);
+ }
+ Assert.Equal(1, database.GetDrawingSet(set.Id)!.Revision);
+ }
+
+ [Fact]
+ public void SetsContaining_ReadsCurrentRevisionsOnlyAndIncludesArchivedSets()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var a = AddDrawing(database, "Synthetic A");
+ var b = AddDrawing(database, "Synthetic B");
+ var kept = Set("b kit", members: (a, 1));
+ var moved = Set("a kit", members: (a, 1));
+ var archived = Set("c kit", members: [(a, 1), (b, 1)]);
+ foreach (var set in new[] { kept, moved, archived })
+ database.InsertDrawingSet(set.Id, set);
+ database.UpdateDrawingSet(moved.Id, Set("a kit", moved.Id, (b, 1)), 1);
+ database.SetDrawingSetArchived(archived.Id, true, 1);
+
+ var usedA = database.DrawingSetsContaining(a, 0, 100)!;
+ Assert.Equal(new[] { "b kit", "c kit" }, usedA.Items.Select(s => s.Name));
+ Assert.Equal(2, usedA.Total);
+ Assert.Equal(new[] { "a kit", "c kit" }, database.DrawingSetsContaining(b, 0, 100)!.Items.Select(s => s.Name));
+ Assert.Equal(new[] { "c kit" }, database.DrawingSetsContaining(a, 1, 1)!.Items.Select(s => s.Name));
+ Assert.Null(database.DrawingSetsContaining(Guid.NewGuid(), 0, 100));
+ }
+
+ [Fact]
+ public void Writes_AreGuardedByRevisionAndArchivedState()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var drawing = AddDrawing(database);
+ var set = Set(members: (drawing, 1));
+ database.InsertDrawingSet(set.Id, set);
+
+ Assert.Equal(1, Assert.Throws(() => database.InsertDrawingSet(set.Id, Set("Other", set.Id))).Current.Revision);
+ Assert.Throws(() => database.UpdateDrawingSet(set.Id, Set("Stale", set.Id), 2));
+ Assert.Throws(() => database.SetDrawingSetArchived(set.Id, false, 1));
+ database.SetDrawingSetArchived(set.Id, true, 1);
+ var refused = Assert.Throws(() => database.UpdateDrawingSet(set.Id, Set("Archived", set.Id), 2));
+ Assert.True(refused.Current.Archived);
+ Assert.Single(refused.Current.Members);
+ Assert.Null(database.UpdateDrawingSet(Guid.NewGuid(), set, 1));
+ Assert.Null(database.SetDrawingSetArchived(Guid.NewGuid(), true, 1));
+ Assert.Null(database.DrawingSetHistory(Guid.NewGuid(), 0, 100));
+ Assert.Equal(2L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_set_revisions"));
+ }
+
+ [Fact]
+ public void DirectWrites_EnforceTheSharedRulesWithNoState()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var drawing = AddDrawing(database);
+ var tooMany = Enumerable.Range(0, DrawingSetRules.MaxMembers + 1).Select(_ => (Guid.NewGuid(), 1)).ToArray();
+
+ Assert.Throws(() => database.InsertDrawingSet(Guid.Empty, Set()));
+ Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(" ")));
+ Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, 0))));
+ Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, DrawingSetRules.MaxQuantity + 1))));
+ Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: [(drawing, 1), (drawing, 2)])));
+ Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: tooMany)));
+ var unknown = Assert.Throws(() => database.InsertDrawingSet(Guid.NewGuid(), Set(members: [(drawing, 1), (Guid.NewGuid(), 1)])));
+ Assert.Contains("is not in the library", unknown.Message);
+ Assert.Throws(() => database.QueryDrawingSets(new DrawingSetQuery { Limit = 501 }));
+ Assert.Throws(() => database.DrawingSetHistory(Guid.NewGuid(), 0, 201));
+ Assert.Throws(() => database.DrawingSetsContaining(drawing, -1, 10));
+
+ Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_set_revisions"));
+ Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_set_members"));
+
+ // The boundaries themselves are accepted, including an empty set.
+ Assert.Equal(0, database.InsertDrawingSet(Guid.NewGuid(), Set()).MemberCount);
+ Assert.Equal(DrawingSetRules.MaxQuantity,
+ database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, DrawingSetRules.MaxQuantity))).Members.Single().Quantity);
+ }
+
+ // Code-level ownership gate, as for the drawing operations.
+ [Theory]
+ [InlineData("GetDrawingSet")]
+ [InlineData("GetDrawingSetRevision")]
+ [InlineData("QueryDrawingSets")]
+ [InlineData("InsertDrawingSet")]
+ [InlineData("UpdateDrawingSet")]
+ [InlineData("SetDrawingSetArchived")]
+ [InlineData("DrawingSetHistory")]
+ [InlineData("DrawingSetsContaining")]
+ public async Task Concurrency_EverySetOperationWaitsForTheSameMonitor(string operation)
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var drawing = AddDrawing(database);
+ var set = Set(members: (drawing, 1));
+ database.InsertDrawingSet(set.Id, set);
+ 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 "GetDrawingSet": database.GetDrawingSet(set.Id); break;
+ case "GetDrawingSetRevision": database.GetDrawingSetRevision(set.Id, 1); break;
+ case "QueryDrawingSets": database.QueryDrawingSets(new DrawingSetQuery { Search = "kit" }); break;
+ case "InsertDrawingSet": database.InsertDrawingSet(Guid.NewGuid(), Set(members: (drawing, 1))); break;
+ case "UpdateDrawingSet": database.UpdateDrawingSet(set.Id, Set("Renamed", set.Id, (drawing, 2)), 1); break;
+ case "SetDrawingSetArchived": database.SetDrawingSetArchived(set.Id, true, 1); break;
+ case "DrawingSetHistory": database.DrawingSetHistory(set.Id, 0, 100); break;
+ case "DrawingSetsContaining": database.DrawingSetsContaining(drawing, 0, 100); 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_AllSetSqlStepsOwnTheMonitor()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var drawing = AddDrawing(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
+ {
+ var set = Set(members: (drawing, 1));
+ Action[] operations =
+ [
+ () => database.InsertDrawingSet(set.Id, set),
+ () => database.GetDrawingSet(set.Id),
+ () => database.GetDrawingSetRevision(set.Id, 1),
+ () => database.QueryDrawingSets(new DrawingSetQuery { Search = "Synthetic", Customer = "synthetic customer" }),
+ () => database.UpdateDrawingSet(set.Id, Set("Renamed", set.Id, (drawing, 2)), 1),
+ () => database.SetDrawingSetArchived(set.Id, true, 2),
+ () => database.SetDrawingSetArchived(set.Id, false, 3),
+ () => database.DrawingSetHistory(set.Id, 0, 100),
+ () => database.DrawingSetsContaining(drawing, 0, 100),
+ ];
+ 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);
+ }
+ }
+
+ ///
+ /// A real schema-v6 file with nests and a library drawing: the set tables are additive,
+ /// so dropping them restores v6 exactly. Returns the drawing's id.
+ ///
+ private static Guid CreateVersion6Database(string path)
+ {
+ Guid drawing;
+ using (var database = new NestDatabase(path, pooling: false))
+ {
+ var nest = new NestRecord { Name = "Synthetic nest", Customer = "Synthetic", Material = "Alloy", Thickness = 0.25 };
+ var stored = database.Insert(Guid.NewGuid(), nest, new byte[] { 0, 1, 255 });
+ nest.Comments = "Edited";
+ database.Update(stored.Id, nest, new byte[] { 2, 3 }, 1);
+ database.AllocateNestNumber(2026);
+ drawing = AddDrawing(database);
+ var edit = database.GetDrawing(drawing)!;
+ edit.Notes = "Edited";
+ 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;");
+ return drawing;
+ }
+
+ // Every pre-v7 table, row by row, so the upgrade is shown to leave them byte-identical.
+ private static string Digest(string path)
+ {
+ using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
+ connection.Open();
+ var text = new StringBuilder();
+ foreach (var table in new[] { "nests", "nest_revisions", "nest_sequences", "drawing_packages", "drawing_revisions", "drawings" })
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = $"SELECT * FROM {table} ORDER BY 1, 2";
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ text.Append(table).Append(':');
+ for (var i = 0; i < reader.FieldCount; i++)
+ {
+ var value = reader.GetValue(i);
+ text.Append(reader.GetName(i)).Append('=')
+ .Append(value is byte[] bytes ? Convert.ToHexString(SHA256.HashData(bytes)) : Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture))
+ .Append(';');
+ }
+ text.Append('\n');
+ }
+ }
+ return text.ToString();
+ }
+
+ private static void Execute(string path, string sql)
+ {
+ using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ 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.Tests/DrawingSetFixtures.cs b/OpenNest.Server.Tests/DrawingSetFixtures.cs
new file mode 100644
index 00000000..26059aa3
--- /dev/null
+++ b/OpenNest.Server.Tests/DrawingSetFixtures.cs
@@ -0,0 +1,53 @@
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using OpenNest.Data;
+using static OpenNest.Server.Tests.DrawingLibraryFixtures;
+
+namespace OpenNest.Server.Tests;
+
+/// Neutral, code-built drawing set fixtures for the server tests.
+internal static class DrawingSetFixtures
+{
+ public static DrawingSetRecord Set(string name = "Synthetic kit", Guid? id = null, params (Guid Drawing, int Quantity)[] members) => new()
+ {
+ Id = id ?? Guid.NewGuid(),
+ Name = name,
+ Customer = "Synthetic customer",
+ Notes = "Synthetic set notes",
+ SavedBy = "Synthetic operator",
+ Members = members.Select(m => new DrawingSetMember { DrawingId = m.Drawing, Quantity = m.Quantity }).ToList(),
+ };
+
+ /// Stores a library drawing directly and returns its id.
+ public static Guid AddDrawing(NestDatabase database, string name = "Synthetic bracket")
+ {
+ var record = Record(name);
+ database.InsertDrawing(record.Id, record, Package(name));
+ return record.Id;
+ }
+
+ public static async Task AddDrawing(HttpClient client, string name = "Synthetic bracket", Action? edit = null)
+ {
+ var record = Record(name);
+ edit?.Invoke(record);
+ return (await Upload(client, record, Package(name))).Id;
+ }
+
+ public static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json");
+
+ public static StringContent Json(DrawingSetRecord record) => Json(JsonSerializer.Serialize(record, Options));
+
+ public static async Task Create(HttpClient client, DrawingSetRecord record)
+ {
+ using var response = await client.PostAsync("/api/sets", Json(record));
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync(Options))!;
+ }
+
+ public static async Task Read(HttpResponseMessage response) =>
+ (await response.Content.ReadFromJsonAsync(Options))!;
+
+ public static IEnumerable<(Guid, int)> MembersOf(DrawingSetRecord record) =>
+ record.Members.Select(m => (m.DrawingId, m.Quantity));
+}
diff --git a/OpenNest.Server/DrawingLibraryRequest.cs b/OpenNest.Server/DrawingLibraryRequest.cs
index 9c1c476c..559b8fdb 100644
--- a/OpenNest.Server/DrawingLibraryRequest.cs
+++ b/OpenNest.Server/DrawingLibraryRequest.cs
@@ -184,8 +184,45 @@ internal static class DrawingLibraryRequest
return error.Length == 0;
}
+ ///
+ /// Reads history-style paging: only offset (zero or greater) and limit
+ /// (1 to ), each at most once.
+ ///
+ public static bool TryReadPaging(IQueryCollection values, out int offset, out int limit, out string error)
+ {
+ offset = 0;
+ limit = DrawingLibraryRules.DefaultRevisionLimit;
+ error = "";
+ foreach (var parameter in values)
+ {
+ if (parameter.Value.Count != 1
+ || !int.TryParse(parameter.Value[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value))
+ {
+ error = "Invalid history pagination.";
+ return false;
+ }
+
+ switch (parameter.Key)
+ {
+ case "offset": offset = value; break;
+ case "limit": limit = value; break;
+ default:
+ error = "Unknown history parameter.";
+ return false;
+ }
+ }
+
+ if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
+ {
+ error = "Invalid history pagination.";
+ return false;
+ }
+
+ return true;
+ }
+
// Names only: Enum.TryParse would also accept numbers, combinations and undefined values.
- private static bool TryEnumName(string text, out TEnum value)
+ internal static bool TryEnumName(string text, out TEnum value)
where TEnum : struct, Enum
{
value = default;
@@ -195,9 +232,9 @@ internal static class DrawingLibraryRequest
&& Enum.IsDefined(value);
}
- private static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..];
+ internal static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..];
- private static bool TryInteger(IQueryCollection values, string key, ref int value, out string error)
+ internal static bool TryInteger(IQueryCollection values, string key, ref int value, out string error)
{
error = "";
if (!values.TryGetValue(key, out var text))
diff --git a/OpenNest.Server/DrawingLibraryRoutes.cs b/OpenNest.Server/DrawingLibraryRoutes.cs
index de7c2564..b1e547fc 100644
--- a/OpenNest.Server/DrawingLibraryRoutes.cs
+++ b/OpenNest.Server/DrawingLibraryRoutes.cs
@@ -108,22 +108,8 @@ internal static class DrawingLibraryRoutes
private static IResult ReadHistory(Guid id, HttpRequest request, NestDatabase db)
{
- var offset = 0;
- var limit = DrawingLibraryRules.DefaultRevisionLimit;
- foreach (var parameter in request.Query)
- {
- if (parameter.Value.Count != 1
- || !int.TryParse(parameter.Value[0], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value))
- return Results.BadRequest("Invalid history pagination.");
- switch (parameter.Key)
- {
- case "offset": offset = value; break;
- case "limit": limit = value; break;
- default: return Results.BadRequest("Unknown history parameter.");
- }
- }
- if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
- return Results.BadRequest("Invalid history pagination.");
+ if (!DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error))
+ return Results.BadRequest(error);
return db.DrawingHistory(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound();
}
diff --git a/OpenNest.Server/DrawingSetRequest.cs b/OpenNest.Server/DrawingSetRequest.cs
new file mode 100644
index 00000000..9ee2997c
--- /dev/null
+++ b/OpenNest.Server/DrawingSetRequest.cs
@@ -0,0 +1,140 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// Parses drawing set requests with the drawing library's strictness: unknown, repeated
+/// or out-of-range values are rejected rather than ignored or clamped, and sort accepts
+/// names only.
+///
+internal static class DrawingSetRequest
+{
+ private static readonly string[] QueryKeys =
+ {
+ "search", "customer", "includeArchived", "sort", "order", "offset", "limit",
+ };
+
+ private static readonly JsonSerializerOptions SetOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public static bool TryParseQuery(IQueryCollection values, out DrawingSetQuery query, out string error)
+ {
+ query = new DrawingSetQuery();
+ foreach (var key in values.Keys)
+ {
+ if (!QueryKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
+ {
+ error = $"Unknown query parameter '{key}'.";
+ return false;
+ }
+
+ if (values[key].Count > 1)
+ {
+ error = $"Query parameter '{key}' was given more than once.";
+ return false;
+ }
+ }
+
+ var sort = DrawingSetSortField.SavedAt;
+ if (values.TryGetValue("sort", out var sortText) && !DrawingLibraryRequest.TryEnumName(sortText.ToString(), out sort))
+ {
+ error = $"sort must be one of: {string.Join(", ", Enum.GetNames().Select(DrawingLibraryRequest.CamelCase))}.";
+ return false;
+ }
+
+ var descending = true;
+ if (values.TryGetValue("order", out var orderText))
+ {
+ if (string.Equals(orderText, "asc", StringComparison.OrdinalIgnoreCase))
+ {
+ descending = false;
+ }
+ else if (!string.Equals(orderText, "desc", StringComparison.OrdinalIgnoreCase))
+ {
+ error = "order must be asc or desc.";
+ return false;
+ }
+ }
+
+ var includeArchived = false;
+ if (values.TryGetValue("includeArchived", out var archivedText) && !bool.TryParse(archivedText.ToString(), out includeArchived))
+ {
+ error = "includeArchived must be true or false.";
+ return false;
+ }
+
+ var offset = 0;
+ var limit = DrawingSetQuery.DefaultLimit;
+ if (!DrawingLibraryRequest.TryInteger(values, "offset", ref offset, out error)
+ || !DrawingLibraryRequest.TryInteger(values, "limit", ref limit, out error))
+ return false;
+
+ query = new DrawingSetQuery
+ {
+ Search = values["search"].ToString(),
+ Customer = values["customer"].ToString(),
+ IncludeArchived = includeArchived,
+ Sort = sort,
+ Descending = descending,
+ Offset = offset,
+ Limit = limit,
+ };
+ error = query.GetValidationError() ?? "";
+ return error.Length == 0;
+ }
+
+ ///
+ /// Reads and validates one set JSON object. members must be present; each
+ /// member's joined drawing is ignored, so a record read from the server may be
+ /// sent back as it is.
+ ///
+ public static bool TryReadSet(string? json, out DrawingSetRecord record, out string error)
+ {
+ record = new DrawingSetRecord();
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ error = "Missing set.";
+ return false;
+ }
+
+ try
+ {
+ if (JsonNode.Parse(json) is not JsonObject root)
+ {
+ error = "The set must be a JSON object.";
+ return false;
+ }
+
+ var members = root.FirstOrDefault(p => string.Equals(p.Key, "members", StringComparison.OrdinalIgnoreCase));
+ if (members.Key is null)
+ {
+ 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)
+ {
+ error = "Invalid set JSON.";
+ return false;
+ }
+
+ error = DrawingSetRules.GetError(record) ?? "";
+ return error.Length == 0;
+ }
+}
diff --git a/OpenNest.Server/DrawingSetRoutes.cs b/OpenNest.Server/DrawingSetRoutes.cs
new file mode 100644
index 00000000..08b5b5b3
--- /dev/null
+++ b/OpenNest.Server/DrawingSetRoutes.cs
@@ -0,0 +1,111 @@
+using System.Text;
+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).
+///
+internal static class DrawingSetRoutes
+{
+ public static void MapDrawingSets(this IEndpointRouteBuilder app)
+ {
+ app.MapGet("/api/sets/query", (HttpRequest request, NestDatabase db) =>
+ DrawingSetRequest.TryParseQuery(request.Query, out var query, out var error)
+ ? Results.Ok(db.QueryDrawingSets(query))
+ : Results.BadRequest(error));
+
+ app.MapGet("/api/sets/{id:guid}", (Guid id, NestDatabase db) =>
+ db.GetDrawingSet(id) is { } record ? Results.Ok(record) : Results.NotFound());
+
+ app.MapGet("/api/sets/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
+ !DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)
+ ? Results.BadRequest(error)
+ : db.DrawingSetHistory(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound());
+
+ app.MapGet("/api/sets/{id:guid}/revisions/{revision:int}", (Guid id, int revision, NestDatabase db) =>
+ revision < 1 ? Results.BadRequest("Invalid revision.") :
+ db.GetDrawingSetRevision(id, revision) is { } record ? Results.Ok(record) : Results.NotFound());
+
+ app.MapGet("/api/drawings/{id:guid}/sets", (Guid id, HttpRequest request, NestDatabase db) =>
+ !DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)
+ ? Results.BadRequest(error)
+ : db.DrawingSetsContaining(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound());
+
+ // JSON set with members; the client allocates the id.
+ app.MapPost("/api/sets", async (HttpRequest request, NestDatabase db) =>
+ {
+ var (record, error) = await ReadSet(request);
+ if (error is not null) return error;
+ if (record!.Id == Guid.Empty)
+ return Results.BadRequest("id is required: allocate it before the first save.");
+ try { return Results.Ok(db.InsertDrawingSet(record.Id, record)); }
+ catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
+ catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); }
+ });
+
+ app.MapPut("/api/sets/{id:guid}", async (Guid id, HttpRequest request, NestDatabase db) =>
+ {
+ var precondition = IfMatch.Read(request, out var expected);
+ if (precondition is not null) return precondition;
+ var (record, error) = await ReadSet(request);
+ if (error is not null) return error;
+ if (record!.Id != Guid.Empty && record.Id != id)
+ return Results.BadRequest("Set id does not match the route.");
+ try
+ {
+ var updated = db.UpdateDrawingSet(id, record, expected);
+ return updated is null ? Results.NotFound() : Results.Ok(updated);
+ }
+ catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
+ catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); }
+ });
+
+ app.MapPost("/api/sets/{id:guid}/archive", (Guid id, HttpRequest request, NestDatabase db) =>
+ SetArchived(id, true, request, db));
+
+ app.MapPost("/api/sets/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) =>
+ SetArchived(id, false, request, db));
+ }
+
+ private static IResult SetArchived(Guid id, bool archived, HttpRequest request, NestDatabase db)
+ {
+ var precondition = IfMatch.Read(request, out var expected);
+ if (precondition is not null) return precondition;
+ try
+ {
+ var changed = db.SetDrawingSetArchived(id, archived, expected);
+ return changed is null ? Results.NotFound() : Results.Ok(changed);
+ }
+ catch (DrawingSetConflictException conflict) { return Conflict(conflict); }
+ }
+
+ private static IResult Conflict(DrawingSetConflictException conflict) =>
+ Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
+
+ private static async Task<(DrawingSetRecord? Record, IResult? Error)> ReadSet(HttpRequest request)
+ {
+ if (!request.HasJsonContentType())
+ return (null, Results.BadRequest("Expected an application/json set."));
+ if (request.ContentLength > DrawingSetRules.MaxBodyBytes)
+ return (null, Results.BadRequest("The set is too large."));
+
+ // Bounded read: a body without a length, or longer than it claims, stops at the limit.
+ using var body = new MemoryStream();
+ var chunk = new byte[81920];
+ int read;
+ 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."));
+ 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));
+ }
+}
diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs
index 47ac7770..9583cacd 100644
--- a/OpenNest.Server/NestDatabase.Drawings.cs
+++ b/OpenNest.Server/NestDatabase.Drawings.cs
@@ -389,32 +389,33 @@ public sealed partial class NestDatabase
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."),
};
- private static DrawingLibraryRecord ReadDrawing(SqliteDataReader reader) => new()
+ // Reads DrawingColumns in order from column `first` on; a join may put other columns before them.
+ private static DrawingLibraryRecord ReadDrawing(SqliteDataReader reader, int first = 0) => new()
{
- Id = Guid.Parse(reader.GetString(0)),
- Revision = reader.GetInt32(1),
- Name = reader.GetString(2),
- Customer = reader.GetString(3),
- MaterialName = reader.GetString(4),
- MaterialGrade = reader.GetString(5),
- Thickness = reader.GetDouble(6),
- Units = Enum.Parse(reader.GetString(7)),
- CutType = Enum.Parse(reader.GetString(8)),
- Notes = reader.GetString(9),
- Archived = reader.GetInt32(10) != 0,
- PackageSha256 = reader.GetString(11),
- PackageSize = reader.GetInt64(12),
- SavedAt = DateTime.Parse(reader.GetString(13)),
- SavedBy = reader.GetString(14),
+ Id = Guid.Parse(reader.GetString(first)),
+ Revision = reader.GetInt32(first + 1),
+ Name = reader.GetString(first + 2),
+ Customer = reader.GetString(first + 3),
+ MaterialName = reader.GetString(first + 4),
+ MaterialGrade = reader.GetString(first + 5),
+ Thickness = reader.GetDouble(first + 6),
+ Units = Enum.Parse(reader.GetString(first + 7)),
+ CutType = Enum.Parse(reader.GetString(first + 8)),
+ Notes = reader.GetString(first + 9),
+ Archived = reader.GetInt32(first + 10) != 0,
+ PackageSha256 = reader.GetString(first + 11),
+ PackageSize = reader.GetInt64(first + 12),
+ SavedAt = DateTime.Parse(reader.GetString(first + 13)),
+ SavedBy = reader.GetString(first + 14),
Settings = new DrawingNestingSettings
{
- CutFirst = reader.GetInt32(15) != 0,
- Priority = reader.GetInt32(16),
- StepAngle = reader.GetDouble(17),
- StartAngle = reader.GetDouble(18),
- EndAngle = reader.GetDouble(19),
- Allow180Equivalent = reader.GetInt32(20) != 0,
- ColorArgb = reader.GetInt32(21),
+ CutFirst = reader.GetInt32(first + 15) != 0,
+ Priority = reader.GetInt32(first + 16),
+ StepAngle = reader.GetDouble(first + 17),
+ StartAngle = reader.GetDouble(first + 18),
+ EndAngle = reader.GetDouble(first + 19),
+ Allow180Equivalent = reader.GetInt32(first + 20) != 0,
+ ColorArgb = reader.GetInt32(first + 21),
},
};
}
diff --git a/OpenNest.Server/NestDatabase.Sets.cs b/OpenNest.Server/NestDatabase.Sets.cs
new file mode 100644
index 00000000..a30553ad
--- /dev/null
+++ b/OpenNest.Server/NestDatabase.Sets.cs
@@ -0,0 +1,391 @@
+using Microsoft.Data.Sqlite;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// Drawing set storage. drawing_set_revisions holds every immutable revision's details
+/// and archived flag, drawing_set_members every revision's ordered members, and
+/// drawing_sets each set's current revision for browsing. Members reference library
+/// drawings by id and are read joined to each drawing's current record. Every change appends
+/// a revision, with its members, in one transaction.
+///
+public sealed partial class NestDatabase
+{
+ private const string SetColumnDefinitions = """
+ id TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ nameKey TEXT NOT NULL,
+ customer TEXT NOT NULL DEFAULT '',
+ customerKey TEXT NOT NULL DEFAULT '',
+ notes TEXT NOT NULL DEFAULT '',
+ archived INTEGER NOT NULL DEFAULT 0,
+ savedAt TEXT NOT NULL,
+ savedBy TEXT NOT NULL DEFAULT ''
+ """;
+
+ private const string DrawingSetSchema = $"""
+ CREATE TABLE drawing_set_revisions ({SetColumnDefinitions}, PRIMARY KEY (id, revision));
+ CREATE TABLE drawing_sets ({SetColumnDefinitions}, PRIMARY KEY (id));
+ CREATE TABLE drawing_set_members (
+ setId TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ drawingId TEXT NOT NULL REFERENCES drawings(id),
+ quantity INTEGER NOT NULL,
+ PRIMARY KEY (setId, revision, position),
+ UNIQUE (setId, revision, drawingId));
+ 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);
+ PRAGMA user_version=7;
+ """;
+
+ private const string SetColumns = "s.id, s.revision, s.name, s.customer, s.notes, s.archived, s.savedAt, s.savedBy";
+
+ private const string SetStoredColumns = "id, revision, name, customer, notes, archived, savedAt, savedBy, nameKey, customerKey";
+
+ // Read after SetColumns: the member count of the row's own revision.
+ private const string SetMemberCount =
+ "(SELECT COUNT(*) FROM drawing_set_members m WHERE m.setId = s.id AND m.revision = s.revision)";
+
+ private const string SetSearchPredicate = """
+ s.name LIKE $pattern ESCAPE '\' OR s.customer LIKE $pattern ESCAPE '\' OR s.notes LIKE $pattern ESCAPE '\'
+ """;
+
+ private static readonly string JoinedDrawingColumns =
+ string.Join(", ", DrawingColumns.Split(',').Select(column => "d." + column.Trim()));
+
+ /// The current revision with its members, or null for an unknown id.
+ public DrawingSetRecord? GetDrawingSet(Guid id)
+ {
+ lock (_sync)
+ {
+ var record = ReadSet($"SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s WHERE s.id=$id", id, null);
+ return record is null ? null : WithMembers(record);
+ }
+ }
+
+ /// One immutable revision with its members, or null when the set or revision is unknown.
+ public DrawingSetRecord? GetDrawingSetRevision(Guid id, int revision)
+ {
+ lock (_sync)
+ {
+ var record = ReadSet(
+ $"SELECT {SetColumns}, {SetMemberCount} FROM drawing_set_revisions s WHERE s.id=$id AND s.revision=$revision",
+ id, revision);
+ return record is null ? null : WithMembers(record);
+ }
+ }
+
+ /// One bounded page of matching sets (without members) plus the total, read in one monitor hold.
+ public DrawingSetPage QueryDrawingSets(DrawingSetQuery query)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ var error = query.GetValidationError();
+ if (error is not null)
+ throw new ArgumentException(error, nameof(query));
+
+ var conditions = new List();
+ if (!query.IncludeArchived) conditions.Add("s.archived=0");
+ if (query.NormalizedSearch.Length > 0) conditions.Add("(" + SetSearchPredicate + ")");
+ if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("s.customerKey=$customer");
+ var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
+
+ lock (_sync)
+ {
+ using var count = _connection.CreateCommand();
+ count.CommandText = $"SELECT COUNT(*) FROM drawing_sets s {where}";
+ AddSetFilterParameters(count, query);
+ var total = checked((int)(long)count.ExecuteScalar()!);
+
+ using var command = _connection.CreateCommand();
+ var direction = query.Descending ? "DESC" : "ASC";
+ command.CommandText = $"""
+ SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s {where}
+ ORDER BY {SetSortColumn(query.Sort)} {direction}, s.id {direction}
+ LIMIT $limit OFFSET $offset
+ """;
+ AddSetFilterParameters(command, query);
+ command.Parameters.AddWithValue("$limit", query.Limit);
+ command.Parameters.AddWithValue("$offset", query.Offset);
+ return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = query.Offset, Limit = query.Limit };
+ }
+ }
+
+ /// Stores revision 1 under a client-allocated id; an existing id is a conflict.
+ public DrawingSetRecord InsertDrawingSet(Guid id, DrawingSetRecord record)
+ {
+ if (id == Guid.Empty) throw new ArgumentException("A set id is required.", nameof(id));
+ ValidateSet(record);
+ lock (_sync)
+ {
+ using var transaction = _connection.BeginTransaction();
+ if (GetDrawingSet(id) is { } existing)
+ throw new DrawingSetConflictException(existing, "A drawing set with this id already exists.");
+ RequireMemberDrawings(record, transaction);
+ WriteSetRevision(id, record, record.Members, 1, archived: false, transaction);
+ transaction.Commit();
+ return GetDrawingSet(id) ?? throw new InvalidOperationException("Insert did not persist.");
+ }
+ }
+
+ ///
+ /// Appends a revision with new details and members. Returns null for an unknown id;
+ /// an identical save (details and members in order) is a no-op.
+ ///
+ public DrawingSetRecord? UpdateDrawingSet(Guid id, DrawingSetRecord record, int expectedRevision)
+ {
+ ValidateSet(record);
+ lock (_sync)
+ {
+ using var transaction = _connection.BeginTransaction();
+ var current = GetDrawingSet(id);
+ if (current is null) return null;
+ if (current.Revision != expectedRevision)
+ throw new DrawingSetConflictException(current, "The drawing set has changed. Reload before saving.");
+ if (current.Archived)
+ throw new DrawingSetConflictException(current, "The drawing set is archived. Restore it before saving.");
+ if (SameSet(current, record))
+ return current;
+
+ RequireMemberDrawings(record, transaction);
+ WriteSetRevision(id, record, record.Members, checked(current.Revision + 1), archived: false, transaction);
+ transaction.Commit();
+ return GetDrawingSet(id);
+ }
+ }
+
+ /// Archives or restores a set as a new revision with the same members. Returns null for an unknown id.
+ public DrawingSetRecord? SetDrawingSetArchived(Guid id, bool archived, int expectedRevision)
+ {
+ lock (_sync)
+ {
+ using var transaction = _connection.BeginTransaction();
+ var current = GetDrawingSet(id);
+ if (current is null) return null;
+ if (current.Revision != expectedRevision)
+ throw new DrawingSetConflictException(current, "The drawing set has changed. Reload and try again.");
+ if (current.Archived == archived)
+ throw new DrawingSetConflictException(current,
+ archived ? "The drawing set is already archived." : "The drawing set is not archived.");
+
+ WriteSetRevision(id, current, current.Members, checked(current.Revision + 1), archived, transaction);
+ transaction.Commit();
+ return GetDrawingSet(id);
+ }
+ }
+
+ /// One page of revisions (without members), newest first, or null for an unknown id.
+ public DrawingSetPage? DrawingSetHistory(Guid id, int offset, int limit)
+ {
+ if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
+ throw new ArgumentOutOfRangeException(nameof(limit));
+ lock (_sync)
+ {
+ if (GetDrawingSet(id) is null) return null;
+ using var count = _connection.CreateCommand();
+ count.CommandText = "SELECT COUNT(*) FROM drawing_set_revisions WHERE id=$id";
+ count.Parameters.AddWithValue("$id", id.ToString());
+ var total = checked((int)(long)count.ExecuteScalar()!);
+ using var command = _connection.CreateCommand();
+ command.CommandText = $"""
+ SELECT {SetColumns}, {SetMemberCount} FROM drawing_set_revisions s WHERE s.id=$id
+ ORDER BY s.revision DESC LIMIT $limit OFFSET $offset
+ """;
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.Parameters.AddWithValue("$limit", limit);
+ command.Parameters.AddWithValue("$offset", offset);
+ return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = offset, Limit = limit };
+ }
+ }
+
+ ///
+ /// The sets, archived ones included, whose current revision contains the drawing, by
+ /// name; null for an unknown drawing.
+ ///
+ public DrawingSetPage? DrawingSetsContaining(Guid drawingId, int offset, int limit)
+ {
+ if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
+ throw new ArgumentOutOfRangeException(nameof(limit));
+ const string where = """
+ WHERE EXISTS (SELECT 1 FROM drawing_set_members m
+ WHERE m.setId = s.id AND m.revision = s.revision AND m.drawingId = $drawing)
+ """;
+ lock (_sync)
+ {
+ if (GetDrawing(drawingId) is null) return null;
+ using var count = _connection.CreateCommand();
+ count.CommandText = $"SELECT COUNT(*) FROM drawing_sets s {where}";
+ count.Parameters.AddWithValue("$drawing", drawingId.ToString());
+ var total = checked((int)(long)count.ExecuteScalar()!);
+ using var command = _connection.CreateCommand();
+ command.CommandText = $"""
+ SELECT {SetColumns}, {SetMemberCount} FROM drawing_sets s {where}
+ ORDER BY s.name COLLATE NOCASE, s.id LIMIT $limit OFFSET $offset
+ """;
+ command.Parameters.AddWithValue("$drawing", drawingId.ToString());
+ command.Parameters.AddWithValue("$limit", limit);
+ command.Parameters.AddWithValue("$offset", offset);
+ return new DrawingSetPage { Items = ReadSets(command), Total = total, Offset = offset, Limit = limit };
+ }
+ }
+
+ private static void ValidateSet(DrawingSetRecord record)
+ {
+ var error = DrawingSetRules.GetError(record);
+ if (error is not null)
+ throw new ArgumentException(error);
+ }
+
+ // Drawings are never deleted, so a member checked here stays valid; archived drawings are allowed.
+ private void RequireMemberDrawings(DrawingSetRecord record, SqliteTransaction transaction)
+ {
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = "SELECT 1 FROM drawings WHERE id=$id";
+ var parameter = command.Parameters.Add("$id", SqliteType.Text);
+ foreach (var member in record.Members)
+ {
+ parameter.Value = member.DrawingId.ToString();
+ if (command.ExecuteScalar() is null)
+ throw new ArgumentException($"Drawing {member.DrawingId} is not in the library.");
+ }
+ }
+
+ // Members compare by drawing and quantity in order; the joined drawings are not part of the set.
+ private static bool SameSet(DrawingSetRecord current, DrawingSetRecord record) =>
+ current.Name == record.Name && current.Customer == record.Customer && current.Notes == record.Notes &&
+ current.Members.Select(m => (m.DrawingId, m.Quantity))
+ .SequenceEqual(record.Members.Select(m => (m.DrawingId, m.Quantity)));
+
+ // Called only under _sync with the caller's transaction; the current row, its revision
+ // snapshot and the revision's members are one write.
+ private void WriteSetRevision(Guid id, DrawingSetRecord record, IReadOnlyList members,
+ int revision, bool archived, SqliteTransaction transaction)
+ {
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = $"""
+ INSERT OR REPLACE INTO drawing_sets ({SetStoredColumns})
+ VALUES ($id, $revision, $name, $customer, $notes, $archived, $savedAt, $savedBy, $nameKey, $customerKey)
+ """;
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.Parameters.AddWithValue("$revision", revision);
+ command.Parameters.AddWithValue("$name", record.Name);
+ command.Parameters.AddWithValue("$customer", record.Customer);
+ command.Parameters.AddWithValue("$notes", record.Notes);
+ command.Parameters.AddWithValue("$archived", archived ? 1 : 0);
+ command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o"));
+ command.Parameters.AddWithValue("$savedBy", record.SavedBy);
+ command.Parameters.AddWithValue("$nameKey", SharedListNames.Key(record.Name));
+ command.Parameters.AddWithValue("$customerKey", SharedListNames.Key(record.Customer));
+ command.ExecuteNonQuery();
+
+ command.Parameters.Clear();
+ command.CommandText = $"INSERT INTO drawing_set_revisions ({SetStoredColumns}) SELECT {SetStoredColumns} FROM drawing_sets WHERE id=$id";
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.ExecuteNonQuery();
+
+ command.Parameters.Clear();
+ command.CommandText = """
+ INSERT INTO drawing_set_members (setId, revision, position, drawingId, quantity)
+ VALUES ($id, $revision, $position, $drawing, $quantity)
+ """;
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.Parameters.AddWithValue("$revision", revision);
+ var position = command.Parameters.Add("$position", SqliteType.Integer);
+ var drawing = command.Parameters.Add("$drawing", SqliteType.Text);
+ var quantity = command.Parameters.Add("$quantity", SqliteType.Integer);
+ for (var i = 0; i < members.Count; i++)
+ {
+ position.Value = i;
+ drawing.Value = members[i].DrawingId.ToString();
+ quantity.Value = members[i].Quantity;
+ command.ExecuteNonQuery();
+ }
+ }
+
+ // Called only under _sync. Reads one record row; the revision parameter is bound when given.
+ private DrawingSetRecord? ReadSet(string sql, Guid id, int? revision)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = sql;
+ command.Parameters.AddWithValue("$id", id.ToString());
+ if (revision is { } value)
+ command.Parameters.AddWithValue("$revision", value);
+ return ReadSets(command).FirstOrDefault();
+ }
+
+ private static List ReadSets(SqliteCommand command)
+ {
+ var records = new List();
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ records.Add(new DrawingSetRecord
+ {
+ Id = Guid.Parse(reader.GetString(0)),
+ Revision = reader.GetInt32(1),
+ Name = reader.GetString(2),
+ Customer = reader.GetString(3),
+ Notes = reader.GetString(4),
+ Archived = reader.GetInt32(5) != 0,
+ SavedAt = DateTime.Parse(reader.GetString(6)),
+ SavedBy = reader.GetString(7),
+ MemberCount = reader.GetInt32(8),
+ });
+ }
+ return records;
+ }
+
+ // Called only under _sync. Members of the record's own revision, each joined to its drawing's current record.
+ private DrawingSetRecord WithMembers(DrawingSetRecord record)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = $"""
+ SELECT m.drawingId, m.quantity, {JoinedDrawingColumns}
+ FROM drawing_set_members m LEFT JOIN drawings d ON d.id = m.drawingId
+ WHERE m.setId=$id AND m.revision=$revision ORDER BY m.position
+ """;
+ command.Parameters.AddWithValue("$id", record.Id.ToString());
+ command.Parameters.AddWithValue("$revision", record.Revision);
+ var members = new List();
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ members.Add(new DrawingSetMember
+ {
+ DrawingId = Guid.Parse(reader.GetString(0)),
+ Quantity = reader.GetInt32(1),
+ Drawing = reader.IsDBNull(2) ? null : ReadDrawing(reader, first: 2),
+ });
+ }
+ record.Members = members;
+ return record;
+ }
+
+ private static void AddSetFilterParameters(SqliteCommand command, DrawingSetQuery query)
+ {
+ AddSearchParameter(command, query.NormalizedSearch);
+ if (SharedListNames.Key(query.Customer).Length > 0)
+ command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.Customer));
+ }
+
+ // Fixed allowlist: request text never reaches the ORDER BY clause.
+ private static string SetSortColumn(DrawingSetSortField field) => field switch
+ {
+ DrawingSetSortField.SavedAt => "s.savedAt",
+ DrawingSetSortField.Name => "s.name COLLATE NOCASE",
+ DrawingSetSortField.Customer => "s.customer COLLATE NOCASE",
+ DrawingSetSortField.Revision => "s.revision",
+ _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."),
+ };
+}
+
+/// A guarded drawing set write found a different current state; nothing was changed.
+public sealed class DrawingSetConflictException(DrawingSetRecord current, string message) : Exception(message)
+{
+ public DrawingSetRecord Current { get; } = current;
+}
diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs
index 3a238d54..5a81a726 100644
--- a/OpenNest.Server/NestDatabase.cs
+++ b/OpenNest.Server/NestDatabase.cs
@@ -319,7 +319,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
- if (version > 6) throw new InvalidOperationException("Database schema is newer than this server.");
+ if (version > 7) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -366,6 +366,11 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = NestSequenceSchema;
command.ExecuteNonQuery();
}
+ if (version < 7)
+ {
+ command.CommandText = DrawingSetSchema;
+ command.ExecuteNonQuery();
+ }
transaction.Commit();
}
}
diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs
index d4151a98..5aeb2292 100644
--- a/OpenNest.Server/Program.cs
+++ b/OpenNest.Server/Program.cs
@@ -140,6 +140,7 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i
});
app.MapDrawingLibrary();
+app.MapDrawingSets();
app.Run();
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index d56e34c7..b729637e 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -327,6 +327,61 @@ its client-allocated id. Saving identical content at the current revision is a
no-op. On success only the nest drawing's library link changes, to the saved
revision; save the nest to keep it.
+## Drawing set endpoints
+
+A **drawing set** (schema v7) is a named, versioned list of library drawings
+with a quantity each: a kit, an assembly or a recurring order. Members name
+drawings by id and always mean each drawing's **current** revision; there is no
+per-member revision pin. Sets travel as JSON (`application/json`, at most 1 MiB).
+The desktop does not use these routes yet.
+
+`OpenNest.Data.DrawingSetRecord` fields: `id` (client-allocated before the
+first save), `revision`, `name`, `customer`, `notes`, `archived`, `savedBy`,
+the server-computed `savedAt` and `memberCount`, and `members`, in order, each
+`{ drawingId, quantity, drawing }`. `drawing` is the member drawing's current
+library record (archived or not), joined by the server on every read so a client
+can group members by material without one request per drawing; it is ignored on
+save, so a record read from the server may be sent back as it is.
+
+| Method | Path | Body | Response |
+|---|---|---|---|
+| POST | `/api/sets` | JSON set with a nonempty `id` and `members` | Revision 1; 409 with the current record when the id exists |
+| GET | `/api/sets/query?search=&customer=&includeArchived=&sort=&order=&offset=&limit=` | — | `{ items, total, offset, limit }`; items have `memberCount` and no `members` |
+| GET | `/api/sets/{id}` | — | Current record with joined members (archived sets included), or 404 |
+| GET | `/api/sets/{id}/revisions?offset=0&limit=100` | — | Revision page without members, newest first; limit 1–200 |
+| GET | `/api/sets/{id}/revisions/{revision}` | — | That revision's details and members, each joined to the drawing's current record |
+| PUT | `/api/sets/{id}` | JSON set (`id` empty or matching the route) | New revision |
+| POST | `/api/sets/{id}/archive` / `/restore` | — | New revision with `archived` changed and the same members |
+| GET | `/api/drawings/{id}/sets?offset=0&limit=100` | — | Sets, archived included, whose current revision contains the drawing, by name; 404 for an unknown drawing |
+
+- Existing-set writes follow the drawing contract: `If-Match` 428 missing, 400
+ malformed, 409 stale with the current record; writing to an archived set,
+ archiving an archived one or restoring an active one is also 409. There is no
+ delete.
+- Every change appends an immutable revision, with its own copy of the members,
+ in the transaction that updates the current row. An identical save (name,
+ customer, notes and members in order) at the current revision is a no-op;
+ reordering members is a change.
+- Validation (400, no state change): `name` nonblank; name, customer and savedBy
+ at most 200 characters, notes at most 4000, no NUL characters; `members`
+ present and not null, at most 500, each with a nonempty `drawingId` that is in
+ the library (archived drawings allowed), an integer `quantity` from 1 to
+ 100000, and no drawing listed twice (one member carries the whole quantity). An
+ empty set is allowed.
+- Query: `search` matches name, customer or notes and `customer` matches by
+ `SharedListNames.Key`, exactly as for drawings. `sort` is one of `savedAt`
+ (default), `name`, `customer`, `revision`; ties break by `id`. Paging, `order`
+ and the refusal of unknown, repeated or out-of-range parameters follow the
+ drawing query.
+
+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
+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
+`restore_check` do not verify them yet.
+
## Storage
`OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with