diff --git a/OpenNest.Core/CutType.cs b/OpenNest.Core/CutType.cs
new file mode 100644
index 00000000..7ccb93df
--- /dev/null
+++ b/OpenNest.Core/CutType.cs
@@ -0,0 +1,13 @@
+namespace OpenNest
+{
+ ///
+ /// Cutting process a library drawing is prepared for, stored by name so new
+ /// processes can be added without a migration.
+ ///
+ public enum CutType
+ {
+ Laser,
+ Waterjet,
+ Plasma,
+ }
+}
diff --git a/OpenNest.Data/DrawingLibraryPage.cs b/OpenNest.Data/DrawingLibraryPage.cs
new file mode 100644
index 00000000..809b9f52
--- /dev/null
+++ b/OpenNest.Data/DrawingLibraryPage.cs
@@ -0,0 +1,15 @@
+namespace OpenNest.Data;
+
+/// One bounded page of library drawing metadata.
+public sealed class DrawingLibraryPage
+{
+ /// 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/DrawingLibraryQuery.cs b/OpenNest.Data/DrawingLibraryQuery.cs
new file mode 100644
index 00000000..65503c5d
--- /dev/null
+++ b/OpenNest.Data/DrawingLibraryQuery.cs
@@ -0,0 +1,68 @@
+using OpenNest;
+
+namespace OpenNest.Data;
+
+///
+/// One bounded page request for browsing the drawing library
+/// (GET /api/drawings/query). The server filters and pages in SQL; the
+/// client and server share these bounds.
+///
+public sealed class DrawingLibraryQuery
+{
+ 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; } = "";
+
+ /// Exact material name, compared by . Blank means any.
+ public string MaterialName { get; init; } = "";
+
+ /// Exact grade, compared by . Blank means any.
+ public string MaterialGrade { get; init; } = "";
+
+ /// Exact thickness after rounding to four decimals; null means any.
+ public double? Thickness { get; init; }
+
+ public CutType? CutType { get; init; }
+
+ public bool IncludeArchived { get; init; }
+
+ /// Column to order by; ties are broken by id in the same direction.
+ public DrawingSortField Sort { get; init; } = DrawingSortField.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 (CutType is { } cutType && !Enum.IsDefined(cutType))
+ return "cutType must be Laser, Waterjet or Plasma.";
+ if (Thickness is { } thickness && (!double.IsFinite(thickness) || thickness < 0))
+ return "thickness must be a finite number, zero or greater.";
+ 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)
+ ?? DrawingLibraryRules.TextError("materialName", MaterialName ?? "", DrawingLibraryRules.MaxTextLength)
+ ?? DrawingLibraryRules.TextError("materialGrade", MaterialGrade ?? "", DrawingLibraryRules.MaxTextLength);
+ }
+}
diff --git a/OpenNest.Data/DrawingLibraryRecord.cs b/OpenNest.Data/DrawingLibraryRecord.cs
new file mode 100644
index 00000000..ecc88a86
--- /dev/null
+++ b/OpenNest.Data/DrawingLibraryRecord.cs
@@ -0,0 +1,50 @@
+using OpenNest;
+
+namespace OpenNest.Data;
+
+///
+/// Server-side metadata for one revision of a shared library drawing. The geometry
+/// travels separately as a drawing package (a single-drawing, zero-plate .nest archive).
+///
+public sealed class DrawingLibraryRecord
+{
+ /// Allocated by the client before the first upload; kept by every revision.
+ public Guid Id { get; set; }
+
+ /// Immutable revision number, starting at 1. Ignored on upload.
+ public int Revision { get; set; }
+
+ public string Name { get; set; } = "";
+
+ public string Customer { get; set; } = "";
+
+ /// Post material name, for example "Carbon Steel".
+ public string MaterialName { get; set; } = "";
+
+ /// Alloy or grade within the material, for example "A36 HR".
+ public string MaterialGrade { get; set; } = "";
+
+ /// Thickness in , stored rounded to four decimals.
+ public double Thickness { get; set; }
+
+ public Units Units { get; set; } = Units.Inches;
+
+ public CutType CutType { get; set; } = CutType.Laser;
+
+ public string Notes { get; set; } = "";
+
+ /// Hidden from browsing by default; still readable by id.
+ public bool Archived { get; set; }
+
+ /// Lower-case hex SHA-256 of the package bytes. Server-computed.
+ public string PackageSha256 { get; set; } = "";
+
+ /// Package size in bytes. Server-computed.
+ public long PackageSize { 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; } = "";
+}
diff --git a/OpenNest.Data/DrawingLibraryRules.cs b/OpenNest.Data/DrawingLibraryRules.cs
new file mode 100644
index 00000000..c23a42d4
--- /dev/null
+++ b/OpenNest.Data/DrawingLibraryRules.cs
@@ -0,0 +1,73 @@
+using OpenNest;
+
+namespace OpenNest.Data;
+
+///
+/// Bounds shared by the drawing library client and server. The client checks them
+/// before sending; the server refuses anything outside them with no state change.
+///
+public static class DrawingLibraryRules
+{
+ /// Longest name, customer, material, grade or saved-by text.
+ public const int MaxTextLength = 200;
+
+ public const int MaxNotesLength = 4000;
+
+ /// Largest accepted drawing package (16 MiB).
+ public const int MaxPackageBytes = 16 * 1024 * 1024;
+
+ public const string PackageTooLargeError = "The drawing package exceeds 16 MiB.";
+
+ public const int DefaultRevisionLimit = 100;
+
+ public const int MaxRevisionLimit = 200;
+
+ /// Thickness as stored and compared: rounded to four decimals.
+ public static double NormalizeThickness(double thickness) =>
+ System.Math.Round(thickness, 4, MidpointRounding.AwayFromZero);
+
+ /// The first metadata rule breaks, or null when it may be saved.
+ public static string? GetMetadataError(DrawingLibraryRecord? record)
+ {
+ if (record is null)
+ return "Missing metadata.";
+ if (string.IsNullOrWhiteSpace(record.Name))
+ return "name is required.";
+ return TextError("name", record.Name, MaxTextLength)
+ ?? TextError("customer", record.Customer, MaxTextLength)
+ ?? TextError("materialName", record.MaterialName, MaxTextLength)
+ ?? TextError("materialGrade", record.MaterialGrade, MaxTextLength)
+ ?? TextError("savedBy", record.SavedBy, MaxTextLength)
+ ?? TextError("notes", record.Notes, MaxNotesLength)
+ ?? (!double.IsFinite(record.Thickness) || record.Thickness < 0
+ ? "thickness must be a finite number, zero or greater."
+ : null)
+ ?? (!Enum.IsDefined(record.Units) ? "units must be a supported unit name." : null)
+ ?? (!Enum.IsDefined(record.CutType) ? "cutType must be Laser, Waterjet or Plasma." : null);
+ }
+
+ /// The first package rule breaks, or null when it may be stored.
+ public static string? GetPackageError(ReadOnlySpan package)
+ {
+ if (package.Length == 0)
+ return "The drawing package is empty.";
+ if (package.Length > MaxPackageBytes)
+ return PackageTooLargeError;
+ // Every ZIP archive, including an empty one, starts with a "PK" record signature.
+ if (package.Length < 4 || package[0] != (byte)'P' || package[1] != (byte)'K')
+ return "The drawing package is not a ZIP archive.";
+ return null;
+ }
+
+ internal static string? TextError(string field, string? value, int maxLength)
+ {
+ if (value is null)
+ return $"{field} is required.";
+ if (value.Length > maxLength)
+ return $"{field} must be at most {maxLength} characters.";
+ // SQLite LIKE stops at an embedded NUL; refuse it everywhere for one rule.
+ if (value.Contains('\0'))
+ return $"{field} must not contain NUL characters.";
+ return null;
+ }
+}
diff --git a/OpenNest.Data/DrawingSortField.cs b/OpenNest.Data/DrawingSortField.cs
new file mode 100644
index 00000000..c309827b
--- /dev/null
+++ b/OpenNest.Data/DrawingSortField.cs
@@ -0,0 +1,14 @@
+namespace OpenNest.Data;
+
+/// Columns a drawing library query may order by (camelCase on the wire).
+public enum DrawingSortField
+{
+ SavedAt,
+ Name,
+ Customer,
+ MaterialName,
+ MaterialGrade,
+ Thickness,
+ CutType,
+ Revision,
+}
diff --git a/OpenNest.Server.Tests/DrawingLibraryApiTests.cs b/OpenNest.Server.Tests/DrawingLibraryApiTests.cs
new file mode 100644
index 00000000..66ba7877
--- /dev/null
+++ b/OpenNest.Server.Tests/DrawingLibraryApiTests.cs
@@ -0,0 +1,514 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Data.Sqlite;
+using OpenNest.Data;
+using static OpenNest.Server.Tests.DrawingLibraryFixtures;
+
+namespace OpenNest.Server.Tests;
+
+public sealed class DrawingLibraryApiTests
+{
+ [Fact]
+ public async Task Upload_StoresRevisionOneWithServerComputedFields()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ record.Thickness = 0.123456;
+ record.Revision = 9;
+ record.PackageSha256 = "client value";
+ record.PackageSize = 1;
+ var package = Package();
+
+ var saved = await Upload(client, record, package);
+
+ Assert.Equal(record.Id, saved.Id);
+ Assert.Equal(1, saved.Revision);
+ Assert.Equal(0.1235, saved.Thickness);
+ Assert.Equal(Sha256(package), saved.PackageSha256);
+ Assert.Equal(package.LongLength, saved.PackageSize);
+ Assert.NotEqual(default, saved.SavedAt);
+ Assert.False(saved.Archived);
+ Assert.Equal(CutType.Laser, saved.CutType);
+ Assert.Equal("Synthetic operator", saved.SavedBy);
+ Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/file"));
+ Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/1/file"));
+ var fetched = await client.GetFromJsonAsync($"/api/drawings/{record.Id}", Options);
+ Assert.Equal(JsonSerializer.Serialize(saved), JsonSerializer.Serialize(fetched));
+ }
+
+ [Fact]
+ public async Task Upload_SerializesEnumsAsCamelCaseNames()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ record.CutType = CutType.Waterjet;
+ await Upload(client, record, Package());
+
+ var json = await client.GetStringAsync($"/api/drawings/{record.Id}");
+
+ var node = JsonNode.Parse(json)!;
+ Assert.Equal("waterjet", node["cutType"]!.GetValue());
+ Assert.Equal("inches", node["units"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task Upload_ExistingId_ConflictsAndChangesNothing()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package("first");
+ await Upload(client, record, package);
+
+ record.Name = "Second writer";
+ using var response = await client.PostAsync("/api/drawings", Body(record, Package("second")));
+
+ Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
+ var current = (await response.Content.ReadFromJsonAsync(Options))!;
+ Assert.Equal("Synthetic bracket", current.Name);
+ Assert.Equal(1, current.Revision);
+ Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/file"));
+ }
+
+ public static TheoryData RejectedUploads()
+ {
+ var valid = JsonSerializer.Serialize(Record(), Options);
+ string With(string property, JsonNode? value)
+ {
+ var node = JsonNode.Parse(valid)!.AsObject();
+ node[property] = value;
+ return node.ToJsonString();
+ }
+
+ return new TheoryData
+ {
+ { "empty id", With("id", Guid.Empty.ToString()), 400 },
+ { "unknown cut type", With("cutType", "Oxyfuel"), 400 },
+ { "integer cut type", With("cutType", 1), 400 },
+ { "combined cut type", With("cutType", "Laser, Plasma"), 400 },
+ { "null cut type", With("cutType", null), 400 },
+ { "unknown units", With("units", "Feet"), 400 },
+ { "integer units", With("units", 0), 400 },
+ { "negative thickness", With("thickness", -0.25), 400 },
+ { "blank name", With("name", " "), 400 },
+ { "null name", With("name", null), 400 },
+ { "long name", With("name", new string('n', 201)), 400 },
+ { "NUL name", With("name", "a\0b"), 400 },
+ { "null customer", With("customer", null), 400 },
+ { "long customer", With("customer", new string('c', 201)), 400 },
+ { "long material", With("materialName", new string('m', 201)), 400 },
+ { "long grade", With("materialGrade", new string('g', 201)), 400 },
+ { "long notes", With("notes", new string('x', 4001)), 400 },
+ { "not an object", "[1]", 400 },
+ { "malformed", "{", 400 },
+ };
+ }
+
+ [Theory]
+ [MemberData(nameof(RejectedUploads))]
+ public async Task Upload_InvalidMetadata_IsRejectedWithNoState(string row, string metadata, int status)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+
+ using var response = await client.PostAsync("/api/drawings", Body(metadata, Package()));
+
+ Assert.True(status == (int)response.StatusCode, row);
+ await AssertEmptyLibrary(client);
+ }
+
+ [Fact]
+ public async Task Upload_BoundaryLengths_AreAccepted()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record(new string('n', 200));
+ record.Notes = new string('x', 4000);
+ record.Customer = new string('c', 200);
+
+ var saved = await Upload(client, record, Package());
+
+ Assert.Equal(200, saved.Name.Length);
+ Assert.Equal(4000, saved.Notes.Length);
+ }
+
+ [Theory]
+ [InlineData("missing")]
+ [InlineData("empty")]
+ [InlineData("not a zip")]
+ [InlineData("too large")]
+ public async Task Upload_InvalidPackage_IsRejectedWithNoState(string row)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var package = row switch
+ {
+ "missing" => null,
+ "empty" => Array.Empty(),
+ "not a zip" => Encoding.UTF8.GetBytes("nest.json"),
+ _ => Enumerable.Repeat((byte)'P', DrawingLibraryRules.MaxPackageBytes + 1).ToArray(),
+ };
+
+ using var response = await client.PostAsync("/api/drawings", Body(Record(), package));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ await AssertEmptyLibrary(client);
+ }
+
+ [Fact]
+ public async Task Upload_PackageAtLimit_IsAccepted()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var package = new byte[DrawingLibraryRules.MaxPackageBytes];
+ package[0] = (byte)'P';
+ package[1] = (byte)'K';
+
+ var saved = await Upload(client, Record(), package);
+
+ Assert.Equal(DrawingLibraryRules.MaxPackageBytes, saved.PackageSize);
+ }
+
+ [Fact]
+ public async Task Upload_NotMultipartOrMissingMetadata_IsRejected()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+
+ using var json = await client.PostAsJsonAsync("/api/drawings", Record(), Options);
+ var withoutMetadata = new MultipartFormDataContent { { new ByteArrayContent(Package()), "package", "d.nest" } };
+ using var missing = await client.PostAsync("/api/drawings", withoutMetadata);
+
+ Assert.Equal(HttpStatusCode.BadRequest, json.StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
+ await AssertEmptyLibrary(client);
+ }
+
+ [Fact]
+ public async Task Reads_UnknownDrawingOrRevision_Are404()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var saved = await Upload(client, Record(), Package());
+ var unknown = Guid.NewGuid();
+
+ foreach (var url in new[]
+ {
+ $"/api/drawings/{unknown}", $"/api/drawings/{unknown}/file", $"/api/drawings/{unknown}/revisions",
+ $"/api/drawings/{unknown}/revisions/1/file", $"/api/drawings/{saved.Id}/revisions/2/file",
+ })
+ {
+ using var response = await client.GetAsync(url);
+ Assert.True(response.StatusCode == HttpStatusCode.NotFound, url);
+ }
+
+ using var zero = await client.GetAsync($"/api/drawings/{saved.Id}/revisions/0/file");
+ Assert.Equal(HttpStatusCode.BadRequest, zero.StatusCode);
+ }
+
+ [Fact]
+ public async Task UpdateFile_AppendsRevisionAndKeepsHistoryExact()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var first = Package("first");
+ await Upload(client, record, first);
+ var second = Package("second");
+ record.Notes = "Changed geometry";
+
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, second)));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var saved = (await response.Content.ReadFromJsonAsync(Options))!;
+ Assert.Equal(2, saved.Revision);
+ Assert.Equal(Sha256(second), saved.PackageSha256);
+ Assert.Equal(second, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/file"));
+ Assert.Equal(first, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/1/file"));
+ Assert.Equal(second, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/2/file"));
+ var history = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options);
+ Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision));
+ Assert.Equal(new[] { Sha256(second), Sha256(first) }, history.Items.Select(r => r.PackageSha256));
+ Assert.Equal(new[] { "Changed geometry", "Synthetic notes" }, history.Items.Select(r => r.Notes));
+ }
+
+ [Fact]
+ public async Task UpdateFile_IdenticalSave_IsNoOp()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package();
+ var saved = await Upload(client, record, package);
+
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, package)));
+
+ var current = (await response.Content.ReadFromJsonAsync(Options))!;
+ Assert.Equal(1, current.Revision);
+ Assert.Equal(saved.SavedAt, current.SavedAt);
+ var history = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options);
+ Assert.Equal(1, history!.Total);
+ }
+
+ [Fact]
+ public async Task UpdateMetadata_AppendsRevisionAndKeepsPackage()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package();
+ await Upload(client, record, package);
+ record.CutType = CutType.Plasma;
+ record.MaterialGrade = "Grade B";
+
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", "\"1\"",
+ JsonContent.Create(record, options: Options)));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var saved = (await response.Content.ReadFromJsonAsync(Options))!;
+ Assert.Equal(2, saved.Revision);
+ Assert.Equal(CutType.Plasma, saved.CutType);
+ Assert.Equal("Grade B", saved.MaterialGrade);
+ Assert.Equal(Sha256(package), saved.PackageSha256);
+ Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/2/file"));
+ }
+
+ [Theory]
+ [InlineData("file", null, 428)]
+ [InlineData("file", "1", 400)]
+ [InlineData("file", "\"0\"", 400)]
+ [InlineData("file", "\"1\", \"2\"", 400)]
+ [InlineData("file", "\"2\"", 409)]
+ [InlineData("metadata", null, 428)]
+ [InlineData("metadata", "\"x\"", 400)]
+ [InlineData("metadata", "\"2\"", 409)]
+ [InlineData("archive", null, 428)]
+ [InlineData("archive", "\"2\"", 409)]
+ [InlineData("restore", "\"1\"", 409)]
+ public async Task GuardedWrite_Refusals_LeaveCurrentAndHistoryUnchanged(string route, string? ifMatch, int status)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package();
+ var saved = await Upload(client, record, package);
+ record.Name = "Rejected";
+ HttpContent? content = route switch
+ {
+ "file" => Body(record, Package("rejected")),
+ "metadata" => JsonContent.Create(record, options: Options),
+ _ => null,
+ };
+ var method = route is "archive" or "restore" ? HttpMethod.Post : HttpMethod.Put;
+
+ using var response = await client.SendAsync(Guarded(method, $"/api/drawings/{record.Id}/{route}", ifMatch, content));
+
+ Assert.Equal(status, (int)response.StatusCode);
+ if (status == 409)
+ Assert.Equal(1, (await response.Content.ReadFromJsonAsync(Options))!.Revision);
+ await AssertUnchanged(factory, client, saved, package);
+ }
+
+ [Fact]
+ public async Task GuardedWrite_UnknownDrawing_Is404()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+
+ using var file = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, Package())));
+ using var metadata = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", "\"1\"",
+ JsonContent.Create(record, options: Options)));
+ using var archive = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/archive", "\"1\""));
+
+ Assert.Equal(HttpStatusCode.NotFound, file.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, metadata.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, archive.StatusCode);
+ await AssertEmptyLibrary(client);
+ }
+
+ [Theory]
+ [InlineData("file")]
+ [InlineData("metadata")]
+ public async Task GuardedWrite_InvalidBodyOrMismatchedId_IsRejected(string route)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package();
+ var saved = await Upload(client, record, package);
+ var other = Record("Other id");
+ HttpContent invalid = route == "file"
+ ? Body("{\"name\":\"\"}", Package())
+ : new StringContent("{\"name\":\"x\",\"cutType\":3}", Encoding.UTF8, "application/json");
+ HttpContent mismatched = route == "file" ? Body(other, Package()) : JsonContent.Create(other, options: Options);
+
+ using var invalidResponse = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/{route}", "\"1\"", invalid));
+ using var mismatchedResponse = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/{route}", "\"1\"", mismatched));
+
+ Assert.Equal(HttpStatusCode.BadRequest, invalidResponse.StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, mismatchedResponse.StatusCode);
+ await AssertUnchanged(factory, client, saved, package);
+ }
+
+ [Fact]
+ public async Task UpdateMetadata_NonJsonBody_IsRejected()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package();
+ var saved = await Upload(client, record, package);
+
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", "\"1\"",
+ new StringContent(JsonSerializer.Serialize(record, Options), Encoding.UTF8, "text/plain")));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ await AssertUnchanged(factory, client, saved, package);
+ }
+
+ [Fact]
+ public async Task ArchiveAndRestore_HideFromBrowsingAndBlockEdits()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package();
+ await Upload(client, record, package);
+
+ using var archived = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/archive", "\"1\""));
+ var archivedRecord = (await archived.Content.ReadFromJsonAsync(Options))!;
+ Assert.True(archivedRecord.Archived);
+ Assert.Equal(2, archivedRecord.Revision);
+ Assert.Equal(Sha256(package), archivedRecord.PackageSha256);
+ Assert.Equal(0, (await Query(client, "")).Total);
+ Assert.Equal(1, (await Query(client, "includeArchived=true")).Total);
+ Assert.True((await client.GetFromJsonAsync($"/api/drawings/{record.Id}", Options))!.Archived);
+
+ using var again = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/archive", "\"2\""));
+ Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
+ record.Name = "Edited while archived";
+ using var edit = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"2\"", Body(record, Package("x"))));
+ Assert.Equal(HttpStatusCode.Conflict, edit.StatusCode);
+
+ using var restored = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/restore", "\"2\""));
+ var restoredRecord = (await restored.Content.ReadFromJsonAsync(Options))!;
+ Assert.False(restoredRecord.Archived);
+ Assert.Equal(3, restoredRecord.Revision);
+ Assert.Equal("Synthetic bracket", restoredRecord.Name);
+ Assert.Equal(1, (await Query(client, "")).Total);
+ var history = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options);
+ Assert.Equal(new[] { false, true, false }, history!.Items.Select(r => r.Archived));
+ Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/2/file"));
+ }
+
+ [Fact]
+ public async Task FailedRevisionInsert_RollsBackCurrentRow()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package();
+ var saved = await Upload(client, record, package);
+ using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False"))
+ {
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = "CREATE TRIGGER fail_revision BEFORE INSERT ON drawing_revisions BEGIN SELECT RAISE(ABORT, 'fixture failure'); END";
+ command.ExecuteNonQuery();
+ }
+ record.Name = "Rejected";
+
+ using var failed = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, Package("x"))));
+ using var insert = await client.PostAsync("/api/drawings", Body(Record("New"), Package()));
+
+ Assert.Equal(HttpStatusCode.InternalServerError, failed.StatusCode);
+ Assert.Equal(HttpStatusCode.InternalServerError, insert.StatusCode);
+ await AssertUnchanged(factory, client, saved, package);
+ Assert.Equal(1, (await Query(client, "")).Total);
+ }
+
+ [Fact]
+ public async Task ConcurrentGuardedSaves_OneWinsAndOneConflicts()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ await Upload(client, record, Package());
+
+ var responses = await Task.WhenAll(Enumerable.Range(2, 2).Select(i =>
+ {
+ var writer = Record($"Writer {i}", record.Id);
+ return client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(writer, Package($"w{i}"))));
+ }));
+ try
+ {
+ Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.OK));
+ var conflict = Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.Conflict));
+ Assert.Equal(2, (await conflict.Content.ReadFromJsonAsync(Options))!.Revision);
+ var history = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options);
+ Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision));
+ }
+ finally { foreach (var response in responses) response.Dispose(); }
+ }
+
+ [Theory]
+ [InlineData("limit=0")]
+ [InlineData("limit=201")]
+ [InlineData("offset=-1")]
+ [InlineData("limit=1&limit=2")]
+ [InlineData("limit=no")]
+ [InlineData("unknown=1")]
+ public async Task History_InvalidPagination_IsRejected(string query)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var saved = await Upload(client, Record(), Package());
+
+ using var response = await client.GetAsync($"/api/drawings/{saved.Id}/revisions?{query}");
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task History_PagesNewestFirst()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ await Upload(client, record, Package());
+ for (var revision = 1; revision <= 3; revision++)
+ {
+ record.Notes = $"Revision {revision + 1}";
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata",
+ $"\"{revision}\"", JsonContent.Create(record, options: Options)));
+ response.EnsureSuccessStatusCode();
+ }
+
+ var page = await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions?offset=1&limit=2", Options);
+
+ Assert.Equal(4, page!.Total);
+ Assert.Equal(new[] { 3, 2 }, page.Items.Select(r => r.Revision));
+ }
+
+ internal static async Task Query(HttpClient client, string query) =>
+ (await client.GetFromJsonAsync("/api/drawings/query?" + query, Options))!;
+
+ private static async Task AssertEmptyLibrary(HttpClient client) =>
+ Assert.Equal(0, (await Query(client, "includeArchived=true")).Total);
+
+ private static async Task AssertUnchanged(ServerFactory factory, HttpClient client, DrawingLibraryRecord saved, byte[] package)
+ {
+ var current = await client.GetFromJsonAsync($"/api/drawings/{saved.Id}", Options);
+ Assert.Equal(JsonSerializer.Serialize(saved), JsonSerializer.Serialize(current));
+ Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{saved.Id}/file"));
+ using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
+ Assert.Equal(1, reopened.DrawingHistory(saved.Id, 0, 100)!.Total);
+ Assert.Equal(package, reopened.GetDrawingRevisionPackage(saved.Id, 1));
+ }
+}
diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
new file mode 100644
index 00000000..e03075bd
--- /dev/null
+++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
@@ -0,0 +1,239 @@
+using System.Reflection;
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.Data.Sqlite;
+using OpenNest.Data;
+using static OpenNest.Server.Tests.DrawingLibraryFixtures;
+
+namespace OpenNest.Server.Tests;
+
+public sealed class DrawingLibraryDatabaseTests
+{
+ [Fact]
+ public void V3Upgrade_KeepsNestRowsByteIdenticalAndIsIdempotent()
+ {
+ using var factory = new ServerFactory();
+ CreateVersion3Database(factory.DatabasePath);
+ var before = NestDigest(factory.DatabasePath);
+
+ for (var open = 0; open < 2; open++)
+ {
+ 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(4L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ }
+
+ using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
+ var record = Record();
+ upgraded.InsertDrawing(record.Id, record, Package());
+ Assert.Equal(1, upgraded.GetDrawing(record.Id)!.Revision);
+ Assert.Equal(2, upgraded.List().Count);
+ }
+
+ [Fact]
+ public void NewerSchema_IsRefused()
+ {
+ using var factory = new ServerFactory();
+ using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
+ Execute(factory.DatabasePath, "PRAGMA user_version=5");
+
+ Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false));
+ }
+
+ [Fact]
+ public void FailedV4Migration_RollsBackAndCanRestart()
+ {
+ using var factory = new ServerFactory();
+ CreateVersion3Database(factory.DatabasePath);
+ var before = NestDigest(factory.DatabasePath);
+ Execute(factory.DatabasePath, "CREATE TABLE drawing_revisions (fixture TEXT)");
+
+ Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false));
+
+ Assert.Equal(3L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name='drawings'"));
+ Assert.Equal(before, NestDigest(factory.DatabasePath));
+ Execute(factory.DatabasePath, "DROP TABLE drawing_revisions");
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ Assert.Equal(2, database.List().Count);
+ }
+
+ [Fact]
+ public void DirectWrites_EnforceTheSharedRules()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var record = Record();
+
+ Assert.Throws(() => database.InsertDrawing(Guid.Empty, record, Package()));
+ Assert.Throws(() => database.InsertDrawing(record.Id, record, new byte[] { 1, 2, 3, 4 }));
+ record.CutType = (CutType)7;
+ Assert.Throws(() => database.InsertDrawing(record.Id, record, Package()));
+ Assert.Throws(() => database.QueryDrawings(new DrawingLibraryQuery { Limit = 501 }));
+ Assert.Throws(() => database.DrawingHistory(record.Id, 0, 201));
+ Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
+ }
+
+ // Code-level ownership gate, as for the nest operations: hold the database's monitor
+ // and observe a dedicated synchronous caller waiting on it.
+ [Theory]
+ [InlineData("GetDrawing")]
+ [InlineData("GetDrawingPackage")]
+ [InlineData("QueryDrawings")]
+ [InlineData("InsertDrawing")]
+ [InlineData("UpdateDrawing")]
+ [InlineData("UpdateDrawingMetadata")]
+ [InlineData("SetDrawingArchived")]
+ [InlineData("DrawingHistory")]
+ [InlineData("GetDrawingRevisionPackage")]
+ public async Task Concurrency_EveryDrawingOperationWaitsForTheSameMonitor(string operation)
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var record = Record();
+ database.InsertDrawing(record.Id, record, Package());
+ 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 "GetDrawing": database.GetDrawing(record.Id); break;
+ case "GetDrawingPackage": database.GetDrawingPackage(record.Id); break;
+ case "QueryDrawings": database.QueryDrawings(new DrawingLibraryQuery { Search = "bracket" }); break;
+ case "InsertDrawing": database.InsertDrawing(Guid.NewGuid(), record, Package("other")); break;
+ case "UpdateDrawing": database.UpdateDrawing(record.Id, record, Package("changed"), 1); break;
+ case "UpdateDrawingMetadata": database.UpdateDrawing(record.Id, Record("Renamed", record.Id), null, 1); break;
+ case "SetDrawingArchived": database.SetDrawingArchived(record.Id, true, 1); break;
+ case "DrawingHistory": database.DrawingHistory(record.Id, 0, 100); break;
+ case "GetDrawingRevisionPackage": database.GetDrawingRevisionPackage(record.Id, 1); 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_AllDrawingSqlStepsOwnTheMonitor()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ 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 record = Record();
+ Action[] operations =
+ [
+ () => database.InsertDrawing(record.Id, record, Package()),
+ () => database.GetDrawing(record.Id),
+ () => database.GetDrawingPackage(record.Id),
+ () => database.QueryDrawings(new DrawingLibraryQuery { Search = "Synthetic", CutType = CutType.Laser, Thickness = 0.25 }),
+ () => database.UpdateDrawing(record.Id, record, Package("changed"), 1),
+ () => database.UpdateDrawing(record.Id, Record("Renamed", record.Id), null, 2),
+ () => database.SetDrawingArchived(record.Id, true, 3),
+ () => database.SetDrawingArchived(record.Id, false, 4),
+ () => database.DrawingHistory(record.Id, 0, 100),
+ () => database.GetDrawingRevisionPackage(record.Id, 1),
+ ];
+ 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-v3 file: v4 is additive, so dropping its tables restores v3 exactly.
+ private static void CreateVersion3Database(string path)
+ {
+ using (var database = new NestDatabase(path, pooling: false))
+ {
+ var first = new NestRecord { Name = "Synthetic nest A", Customer = "Synthetic", Material = "Alloy", Thickness = 0.25 };
+ var second = new NestRecord { Name = "Synthetic nest B", Comments = "Notes" };
+ var a = database.Insert(Guid.NewGuid(), first, new byte[] { 0, 1, 255 });
+ database.Insert(Guid.NewGuid(), second, new byte[] { 7, 8, 9 });
+ first.Comments = "Edited";
+ database.Update(a.Id, first, new byte[] { 2, 3 }, 1);
+ database.SetDeleted(a.Id, true, 2);
+ database.SetDeleted(a.Id, false, 3);
+ }
+
+ Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; PRAGMA user_version=3;");
+ }
+
+ private static string NestDigest(string path)
+ {
+ using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
+ connection.Open();
+ var text = new StringBuilder();
+ foreach (var sql in new[] { "SELECT * FROM nests ORDER BY id", "SELECT * FROM nest_revisions ORDER BY id, revision" })
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ 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/DrawingLibraryFixtures.cs b/OpenNest.Server.Tests/DrawingLibraryFixtures.cs
new file mode 100644
index 00000000..6be9da27
--- /dev/null
+++ b/OpenNest.Server.Tests/DrawingLibraryFixtures.cs
@@ -0,0 +1,73 @@
+using System.IO.Compression;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenNest.Data;
+
+namespace OpenNest.Server.Tests;
+
+/// Neutral, code-built drawing library fixtures for the server tests.
+internal static class DrawingLibraryFixtures
+{
+ public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
+ { Converters = { new JsonStringEnumConverter() } };
+
+ public static DrawingLibraryRecord Record(string name = "Synthetic bracket", Guid? id = null) => new()
+ {
+ Id = id ?? Guid.NewGuid(),
+ Name = name,
+ Customer = "Synthetic customer",
+ MaterialName = "Synthetic Steel",
+ MaterialGrade = "Grade A",
+ Thickness = 0.25,
+ Units = Units.Inches,
+ CutType = CutType.Laser,
+ Notes = "Synthetic notes",
+ SavedBy = "Synthetic operator",
+ };
+
+ /// A real ZIP archive whose single entry holds .
+ public static byte[] Package(string content = "synthetic")
+ {
+ using var buffer = new MemoryStream();
+ using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ var entry = archive.CreateEntry("nest.json");
+ entry.LastWriteTime = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ using var stream = entry.Open();
+ stream.Write(Encoding.UTF8.GetBytes(content));
+ }
+ return buffer.ToArray();
+ }
+
+ public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+
+ public static MultipartFormDataContent Body(string metadataJson, byte[]? package)
+ {
+ var body = new MultipartFormDataContent();
+ body.Add(new StringContent(metadataJson), "metadata");
+ if (package is not null)
+ body.Add(new ByteArrayContent(package), "package", "drawing.nest");
+ return body;
+ }
+
+ public static MultipartFormDataContent Body(DrawingLibraryRecord record, byte[]? package) =>
+ Body(JsonSerializer.Serialize(record, Options), package);
+
+ public static async Task Upload(HttpClient client, DrawingLibraryRecord record, byte[] package)
+ {
+ using var response = await client.PostAsync("/api/drawings", Body(record, package));
+ response.EnsureSuccessStatusCode();
+ return (await response.Content.ReadFromJsonAsync(Options))!;
+ }
+
+ public static HttpRequestMessage Guarded(HttpMethod method, string url, string? ifMatch, HttpContent? content = null)
+ {
+ var request = new HttpRequestMessage(method, url) { Content = content };
+ if (ifMatch is not null)
+ request.Headers.TryAddWithoutValidation("If-Match", ifMatch);
+ return request;
+ }
+}
diff --git a/OpenNest.Server.Tests/DrawingLibraryQueryTests.cs b/OpenNest.Server.Tests/DrawingLibraryQueryTests.cs
new file mode 100644
index 00000000..d64cba75
--- /dev/null
+++ b/OpenNest.Server.Tests/DrawingLibraryQueryTests.cs
@@ -0,0 +1,230 @@
+using System.Net;
+using System.Net.Http.Json;
+using OpenNest.Data;
+using static OpenNest.Server.Tests.DrawingLibraryFixtures;
+
+namespace OpenNest.Server.Tests;
+
+public sealed class DrawingLibraryQueryTests
+{
+ private static async Task> SeedNamed(HttpClient client)
+ {
+ var rows = new (string Name, string Customer, string Material, string Grade, double Thickness, CutType CutType, string Notes)[]
+ {
+ ("Alpha bracket", "Alpha Fabrication", "Carbon Steel", "A36 HR", 0.25, CutType.Laser, ""),
+ ("Beta plate", "beta works", "carbon steel", " a36 hr", 0.1875, CutType.Waterjet, "Gamma 50% notes"),
+ ("Delta_Under", "Gamma 50% Shop", "Stainless", "304", 0.25, CutType.Plasma, ""),
+ ("Echo", "Alpha Fabrication", "Carbon Steel", "A36 HR", 0.25004, CutType.Laser, ""),
+ };
+ var saved = new Dictionary();
+ foreach (var row in rows)
+ {
+ var record = Record(row.Name);
+ record.Customer = row.Customer;
+ record.MaterialName = row.Material;
+ record.MaterialGrade = row.Grade;
+ record.Thickness = row.Thickness;
+ record.CutType = row.CutType;
+ record.Notes = row.Notes;
+ saved[row.Name] = await Upload(client, record, Package(row.Name));
+ }
+
+ using var archived = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{saved["Echo"].Id}/archive", "\"1\""));
+ archived.EnsureSuccessStatusCode();
+ return saved;
+ }
+
+ [Theory]
+ [InlineData("", "Alpha bracket|Beta plate|Delta_Under")]
+ [InlineData("includeArchived=true", "Alpha bracket|Beta plate|Delta_Under|Echo")]
+ [InlineData("includeArchived=false", "Alpha bracket|Beta plate|Delta_Under")]
+ [InlineData("cutType=laser", "Alpha bracket")]
+ [InlineData("cutType=LASER&includeArchived=true", "Alpha bracket|Echo")]
+ [InlineData("cutType=Waterjet", "Beta plate")]
+ [InlineData("materialName=CARBON%20%20steel%20", "Alpha bracket|Beta plate")]
+ [InlineData("materialGrade=a36%20HR", "Alpha bracket|Beta plate")]
+ [InlineData("customer=ALPHA%20fabrication", "Alpha bracket")]
+ [InlineData("customer=Beta%20Works", "Beta plate")]
+ [InlineData("thickness=0.25", "Alpha bracket|Delta_Under")]
+ [InlineData("thickness=0.25&includeArchived=true", "Alpha bracket|Delta_Under|Echo")]
+ [InlineData("thickness=0.1875", "Beta plate")]
+ [InlineData("thickness=0.1874", "")]
+ [InlineData("thickness=0.25&materialName=Carbon%20Steel&cutType=laser", "Alpha bracket")]
+ [InlineData("materialName=Stainless&cutType=laser", "")]
+ [InlineData("customer=&materialName=", "Alpha bracket|Beta plate|Delta_Under")]
+ [InlineData("search=0%25", "Beta plate|Delta_Under")]
+ [InlineData("search=_", "Delta_Under")]
+ [InlineData("search=%5C", "")]
+ [InlineData("search=%20GAMMA%20", "Beta plate|Delta_Under")]
+ [InlineData("search=stainless", "")]
+ [InlineData("search=bracket&cutType=plasma", "")]
+ public async Task Filters_MatchExactKeysAndLiteralSearch(string query, string expected)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ await SeedNamed(client);
+
+ var page = await DrawingLibraryApiTests.Query(client, query + "&sort=name&order=asc");
+
+ Assert.Equal(expected, string.Join("|", page.Items.Select(r => r.Name)));
+ Assert.Equal(page.Items.Count, page.Total);
+ }
+
+ [Theory]
+ [InlineData("name", false)]
+ [InlineData("name", true)]
+ [InlineData("customer", false)]
+ [InlineData("materialName", true)]
+ [InlineData("materialGrade", false)]
+ [InlineData("thickness", true)]
+ [InlineData("thickness", false)]
+ [InlineData("cutType", false)]
+ [InlineData("revision", true)]
+ public async Task Sort_MatchesOracleAndPagesPartitionMatches(string sort, bool descending)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ string[] names = { "beta", "Alpha", "alpha", "BETA", "gamma" };
+ string[] grades = { "a36", "A36", "304" };
+ double[] thicknesses = { 0.25, 0.1875, 0.25 };
+ CutType[] cutTypes = { CutType.Plasma, CutType.Laser, CutType.Waterjet };
+ var saved = new List();
+ for (var i = 0; i < 13; i++)
+ {
+ var record = Record(names[i % names.Length]);
+ record.Customer = names[(i + 2) % names.Length];
+ record.MaterialName = names[(i + 1) % names.Length];
+ record.MaterialGrade = grades[i % grades.Length];
+ record.Thickness = thicknesses[i % thicknesses.Length];
+ record.CutType = cutTypes[i % cutTypes.Length];
+ var uploaded = await Upload(client, record, Package(i.ToString()));
+ if (i % 4 == 0)
+ {
+ record.Notes = "edited";
+ using var edit = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", "\"1\"",
+ JsonContent.Create(record, options: Options)));
+ uploaded = (await edit.Content.ReadFromJsonAsync(Options))!;
+ }
+ saved.Add(uploaded);
+ }
+
+ var expected = Oracle(saved, sort, descending).Select(r => r.Id).ToList();
+ var order = descending ? "desc" : "asc";
+ var full = await DrawingLibraryApiTests.Query(client, $"sort={sort}&order={order}");
+ var paged = new List();
+ for (var offset = 0; offset < saved.Count; offset += 5)
+ paged.AddRange((await DrawingLibraryApiTests.Query(client, $"sort={sort}&order={order}&offset={offset}&limit=5")).Items.Select(r => r.Id));
+
+ Assert.Equal(expected, full.Items.Select(r => r.Id));
+ Assert.Equal(expected, paged);
+ Assert.Equal(saved.Count, full.Total);
+ }
+
+ [Fact]
+ public async Task DefaultOrder_IsNewestSavedFirst()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var first = await Upload(client, Record("First"), Package("1"));
+ var second = await Upload(client, Record("Second"), Package("2"));
+
+ var page = await DrawingLibraryApiTests.Query(client, "");
+
+ var expected = new[] { first, second }
+ .OrderByDescending(r => r.SavedAt.ToString("o"), StringComparer.Ordinal)
+ .ThenByDescending(r => r.Id.ToString(), StringComparer.Ordinal)
+ .Select(r => r.Id);
+ Assert.Equal(expected, page.Items.Select(r => r.Id));
+ }
+
+ [Fact]
+ public async Task Limit_BoundsThePageAndTotalCountsEveryMatch()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ for (var i = 0; i < 3; i++)
+ await Upload(client, Record($"Drawing {i}"), Package(i.ToString()));
+
+ var page = await DrawingLibraryApiTests.Query(client, "limit=2&offset=1");
+ var maximum = await DrawingLibraryApiTests.Query(client, "limit=500");
+ var beyond = await DrawingLibraryApiTests.Query(client, "offset=2147483647");
+
+ Assert.Equal(2, page.Items.Count);
+ Assert.Equal(3, page.Total);
+ Assert.Equal(1, page.Offset);
+ Assert.Equal(2, page.Limit);
+ Assert.Equal(3, maximum.Items.Count);
+ Assert.Empty(beyond.Items);
+ Assert.Equal(3, beyond.Total);
+ }
+
+ [Theory]
+ [InlineData("unknown=1")]
+ [InlineData("limit=0")]
+ [InlineData("limit=501")]
+ [InlineData("limit=-1")]
+ [InlineData("offset=-1")]
+ [InlineData("offset=x")]
+ [InlineData("sort=1")]
+ [InlineData("sort=package")]
+ [InlineData("sort=name%20desc")]
+ [InlineData("order=up")]
+ [InlineData("cutType=1")]
+ [InlineData("cutType=Laser,Plasma")]
+ [InlineData("cutType=oxyfuel")]
+ [InlineData("cutType=")]
+ [InlineData("cutType=laser&cutType=plasma")]
+ [InlineData("thickness=-1")]
+ [InlineData("thickness=NaN")]
+ [InlineData("thickness=Infinity")]
+ [InlineData("thickness=1e3")]
+ [InlineData("thickness=")]
+ [InlineData("includeArchived=yes")]
+ [InlineData("search=a%00b")]
+ [InlineData("customer=a%00b")]
+ public async Task InvalidParameters_AreRejected(string query)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+
+ using var response = await client.GetAsync("/api/drawings/query?" + query);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task OverlongSearchOrFilter_IsRejected()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+
+ using var search = await client.GetAsync("/api/drawings/query?search=" + new string('s', 201));
+ using var customer = await client.GetAsync("/api/drawings/query?customer=" + new string('c', 201));
+ using var boundary = await client.GetAsync("/api/drawings/query?search=" + new string('s', 200));
+
+ Assert.Equal(HttpStatusCode.BadRequest, search.StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest, customer.StatusCode);
+ Assert.Equal(HttpStatusCode.OK, boundary.StatusCode);
+ }
+
+ /// Independent oracle: ASCII case-folded text or numeric value, then id, in one direction.
+ private static IEnumerable Oracle(List records, string sort, bool descending)
+ {
+ Func key = sort switch
+ {
+ "name" => r => r.Name.ToUpperInvariant(),
+ "customer" => r => r.Customer.ToUpperInvariant(),
+ "materialName" => r => r.MaterialName.ToUpperInvariant(),
+ "materialGrade" => r => r.MaterialGrade.ToUpperInvariant(),
+ "thickness" => r => r.Thickness,
+ "cutType" => r => r.CutType.ToString(),
+ "revision" => r => r.Revision,
+ _ => throw new ArgumentOutOfRangeException(nameof(sort)),
+ };
+ var comparer = Comparer.Create((a, b) =>
+ a is string x && b is string y ? string.CompareOrdinal(x, y) : a.CompareTo(b));
+ return descending
+ ? records.OrderByDescending(key, comparer).ThenByDescending(r => r.Id.ToString(), StringComparer.Ordinal)
+ : records.OrderBy(key, comparer).ThenBy(r => r.Id.ToString(), StringComparer.Ordinal);
+ }
+}
diff --git a/OpenNest.Server/DrawingLibraryRequest.cs b/OpenNest.Server/DrawingLibraryRequest.cs
new file mode 100644
index 00000000..b5c634ad
--- /dev/null
+++ b/OpenNest.Server/DrawingLibraryRequest.cs
@@ -0,0 +1,199 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenNest;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// Parses drawing library requests. Unknown, repeated or out-of-range values are
+/// rejected rather than ignored or clamped, and enum fields accept names only.
+///
+internal static class DrawingLibraryRequest
+{
+ private static readonly string[] QueryKeys =
+ {
+ "search", "customer", "materialName", "materialGrade", "thickness", "cutType",
+ "includeArchived", "sort", "order", "offset", "limit",
+ };
+
+ // Integer enum values are refused; names are additionally checked before deserializing.
+ private static readonly JsonSerializerOptions MetadataOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) },
+ };
+
+ public static bool TryParseQuery(IQueryCollection values, out DrawingLibraryQuery query, out string error)
+ {
+ query = new DrawingLibraryQuery();
+ 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 = DrawingSortField.SavedAt;
+ if (values.TryGetValue("sort", out var sortText) && !TryEnumName(sortText.ToString(), out sort))
+ {
+ error = $"sort must be one of: {string.Join(", ", Enum.GetNames().Select(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;
+ }
+ }
+
+ CutType? cutType = null;
+ if (values.TryGetValue("cutType", out var cutTypeText))
+ {
+ if (!TryEnumName(cutTypeText.ToString(), out var parsed))
+ {
+ error = "cutType must be Laser, Waterjet or Plasma.";
+ return false;
+ }
+
+ cutType = parsed;
+ }
+
+ double? thickness = null;
+ if (values.TryGetValue("thickness", out var thicknessText))
+ {
+ if (!double.TryParse(thicknessText.ToString(), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var parsed)
+ || !double.IsFinite(parsed))
+ {
+ error = "thickness must be a finite number, zero or greater.";
+ return false;
+ }
+
+ thickness = parsed;
+ }
+
+ 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 = DrawingLibraryQuery.DefaultLimit;
+ if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error))
+ return false;
+
+ query = new DrawingLibraryQuery
+ {
+ Search = values["search"].ToString(),
+ Customer = values["customer"].ToString(),
+ MaterialName = values["materialName"].ToString(),
+ MaterialGrade = values["materialGrade"].ToString(),
+ Thickness = thickness,
+ CutType = cutType,
+ IncludeArchived = includeArchived,
+ Sort = sort,
+ Descending = descending,
+ Offset = offset,
+ Limit = limit,
+ };
+ error = query.GetValidationError() ?? "";
+ return error.Length == 0;
+ }
+
+ /// Reads and validates one metadata JSON object.
+ public static bool TryReadMetadata(string? json, out DrawingLibraryRecord record, out string error)
+ {
+ record = new DrawingLibraryRecord();
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ error = "Missing metadata.";
+ return false;
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ error = "Metadata must be a JSON object.";
+ return false;
+ }
+
+ // Enum converters also accept comma-separated combinations; allow single names only.
+ foreach (var property in document.RootElement.EnumerateObject())
+ {
+ if (string.Equals(property.Name, "cutType", StringComparison.OrdinalIgnoreCase))
+ {
+ if (property.Value.ValueKind != JsonValueKind.String || !TryEnumName(property.Value.GetString()!, out _))
+ {
+ error = "cutType must be Laser, Waterjet or Plasma.";
+ return false;
+ }
+ }
+ else if (string.Equals(property.Name, "units", StringComparison.OrdinalIgnoreCase))
+ {
+ if (property.Value.ValueKind != JsonValueKind.String || !TryEnumName(property.Value.GetString()!, out _))
+ {
+ error = "units must be a supported unit name.";
+ return false;
+ }
+ }
+ }
+
+ record = document.RootElement.Deserialize(MetadataOptions) ?? record;
+ }
+ catch (JsonException)
+ {
+ error = "Invalid metadata JSON.";
+ return false;
+ }
+
+ error = DrawingLibraryRules.GetMetadataError(record) ?? "";
+ return error.Length == 0;
+ }
+
+ // Names only: Enum.TryParse would also accept numbers, combinations and undefined values.
+ private static bool TryEnumName(string text, out TEnum value)
+ where TEnum : struct, Enum
+ {
+ value = default;
+ return text.Length > 0
+ && text.All(char.IsAsciiLetter)
+ && Enum.TryParse(text, ignoreCase: true, out value)
+ && Enum.IsDefined(value);
+ }
+
+ private 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)
+ {
+ error = "";
+ if (!values.TryGetValue(key, out var text))
+ return true;
+
+ if (int.TryParse(text.ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out value))
+ return true;
+
+ error = $"{key} must be a non-negative integer.";
+ return false;
+ }
+}
diff --git a/OpenNest.Server/DrawingLibraryRoutes.cs b/OpenNest.Server/DrawingLibraryRoutes.cs
new file mode 100644
index 00000000..de7c2564
--- /dev/null
+++ b/OpenNest.Server/DrawingLibraryRoutes.cs
@@ -0,0 +1,162 @@
+using System.Globalization;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// /api/drawings: the shared drawing library. Geometry travels as an opaque
+/// drawing package; existing-drawing writes follow the nest If-Match contract
+/// (428 missing, 400 malformed, 409 stale or wrong state with the current record).
+///
+internal static class DrawingLibraryRoutes
+{
+ // Metadata is a few kilobytes at most (notes are limited to 4000 characters).
+ private const int MaxMetadataBytes = 64 * 1024;
+
+ public static void MapDrawingLibrary(this IEndpointRouteBuilder app)
+ {
+ app.MapGet("/api/drawings/query", (HttpRequest request, NestDatabase db) =>
+ DrawingLibraryRequest.TryParseQuery(request.Query, out var query, out var error)
+ ? Results.Ok(db.QueryDrawings(query))
+ : Results.BadRequest(error));
+
+ app.MapGet("/api/drawings/{id:guid}", (Guid id, NestDatabase db) =>
+ db.GetDrawing(id) is { } record ? Results.Ok(record) : Results.NotFound());
+
+ app.MapGet("/api/drawings/{id:guid}/file", (Guid id, NestDatabase db) =>
+ db.GetDrawingPackage(id) is { } package
+ ? Results.File(package, "application/zip", $"{id}.nest")
+ : Results.NotFound());
+
+ app.MapGet("/api/drawings/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
+ ReadHistory(id, request, db));
+
+ app.MapGet("/api/drawings/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
+ revision < 1 ? Results.BadRequest("Invalid revision.") :
+ db.GetDrawingRevisionPackage(id, revision) is { } package
+ ? Results.File(package, "application/zip", $"{id}-r{revision.ToString(CultureInfo.InvariantCulture)}.nest")
+ : Results.NotFound());
+
+ // Multipart: "metadata" JSON part + "package" part. The client allocates the id.
+ app.MapPost("/api/drawings", async (HttpContext http, NestDatabase db) =>
+ {
+ var (record, package, error) = await ReadUpload(http);
+ if (error is not null) return error;
+ if (record!.Id == Guid.Empty)
+ return Results.BadRequest("id is required: allocate it before the first upload.");
+ try { return Results.Ok(db.InsertDrawing(record.Id, record, package!)); }
+ catch (DrawingConflictException conflict) { return Conflict(conflict); }
+ });
+
+ app.MapPut("/api/drawings/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) =>
+ {
+ var precondition = IfMatch.Read(http.Request, out var expected);
+ if (precondition is not null) return precondition;
+ var (record, package, error) = await ReadUpload(http);
+ if (error is not null) return error;
+ return Update(id, record!, package, expected, db);
+ });
+
+ app.MapPut("/api/drawings/{id:guid}/metadata", async (Guid id, HttpRequest request, NestDatabase db) =>
+ {
+ var precondition = IfMatch.Read(request, out var expected);
+ if (precondition is not null) return precondition;
+ if (!request.HasJsonContentType())
+ return Results.BadRequest("Expected application/json metadata.");
+ using var reader = new StreamReader(request.Body);
+ var json = await reader.ReadToEndAsync(request.HttpContext.RequestAborted);
+ if (json.Length > MaxMetadataBytes)
+ return Results.BadRequest("Metadata is too large.");
+ if (!DrawingLibraryRequest.TryReadMetadata(json, out var record, out var error))
+ return Results.BadRequest(error);
+ return Update(id, record, null, expected, db);
+ });
+
+ app.MapPost("/api/drawings/{id:guid}/archive", (Guid id, HttpRequest request, NestDatabase db) =>
+ SetArchived(id, true, request, db));
+
+ app.MapPost("/api/drawings/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) =>
+ SetArchived(id, false, request, db));
+ }
+
+ private static IResult Update(Guid id, DrawingLibraryRecord record, byte[]? package, int expected, NestDatabase db)
+ {
+ if (record.Id != Guid.Empty && record.Id != id)
+ return Results.BadRequest("Metadata id does not match the drawing.");
+ try
+ {
+ var updated = db.UpdateDrawing(id, record, package, expected);
+ return updated is null ? Results.NotFound() : Results.Ok(updated);
+ }
+ catch (DrawingConflictException conflict) { return Conflict(conflict); }
+ }
+
+ 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.SetDrawingArchived(id, archived, expected);
+ return changed is null ? Results.NotFound() : Results.Ok(changed);
+ }
+ catch (DrawingConflictException conflict) { return Conflict(conflict); }
+ }
+
+ private static IResult Conflict(DrawingConflictException conflict) =>
+ Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
+
+ 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.");
+ return db.DrawingHistory(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound();
+ }
+
+ // Returns (record, package, error); the package is checked before it is buffered.
+ private static async Task<(DrawingLibraryRecord? Record, byte[]? Package, IResult? Error)> ReadUpload(HttpContext http)
+ {
+ if (!http.Request.HasFormContentType)
+ return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and package parts."));
+
+ IFormCollection form;
+ try
+ {
+ form = await http.Request.ReadFormAsync();
+ }
+ catch (Exception ex) when (ex is InvalidDataException || (ex is IOException && ex is not BadHttpRequestException))
+ {
+ return (null, null, Results.BadRequest("Malformed multipart body."));
+ }
+
+ if (!DrawingLibraryRequest.TryReadMetadata(form["metadata"].FirstOrDefault(), out var record, out var error))
+ return (null, null, Results.BadRequest(error));
+
+ var packagePart = form.Files["package"];
+ if (packagePart is null || packagePart.Length == 0)
+ return (null, null, Results.BadRequest("Missing or empty 'package' part."));
+ if (packagePart.Length > DrawingLibraryRules.MaxPackageBytes)
+ return (null, null, Results.BadRequest(DrawingLibraryRules.PackageTooLargeError));
+
+ using var stream = packagePart.OpenReadStream();
+ using var buffer = new MemoryStream((int)packagePart.Length);
+ await stream.CopyToAsync(buffer);
+ var package = buffer.ToArray();
+ var packageError = DrawingLibraryRules.GetPackageError(package);
+ return packageError is null ? (record, package, null) : (null, null, Results.BadRequest(packageError));
+ }
+}
diff --git a/OpenNest.Server/IfMatch.cs b/OpenNest.Server/IfMatch.cs
new file mode 100644
index 00000000..cae6fa3d
--- /dev/null
+++ b/OpenNest.Server/IfMatch.cs
@@ -0,0 +1,22 @@
+namespace OpenNest.Server;
+
+/// The revision precondition every existing-record mutation requires.
+internal static class IfMatch
+{
+ ///
+ /// Reads one quoted positive revision. Returns 428 when the header is missing and
+ /// 400 when it is malformed; null means holds the value.
+ ///
+ public static IResult? Read(HttpRequest request, out int revision)
+ {
+ revision = 0;
+ var values = request.Headers.IfMatch;
+ if (values.Count == 0) return Results.Json("Upgrade/reload required: send If-Match with the current revision.", statusCode: 428);
+ var text = values.Count == 1 ? values[0] : null;
+ if (text is null || text.Length < 3 || text[0] != '"' || text[^1] != '"' ||
+ !text[1..^1].All(c => c is >= '0' and <= '9') ||
+ !int.TryParse(text[1..^1], out revision) || revision < 1)
+ return Results.BadRequest("If-Match must contain one quoted positive integer revision.");
+ return null;
+ }
+}
diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs
new file mode 100644
index 00000000..36a20d9b
--- /dev/null
+++ b/OpenNest.Server/NestDatabase.Drawings.cs
@@ -0,0 +1,339 @@
+using System.Security.Cryptography;
+using Microsoft.Data.Sqlite;
+using OpenNest;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// Drawing library storage: drawings holds each drawing's current revision and
+/// drawing_revisions every immutable revision, both in the nest database file
+/// and under the same monitor. Every change appends a revision in one transaction.
+///
+public sealed partial class NestDatabase
+{
+ // Schema v4 is additive: the nest tables are untouched.
+ private const string DrawingColumnDefinitions = """
+ id TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ customer TEXT NOT NULL DEFAULT '',
+ nameKey TEXT NOT NULL,
+ customerKey TEXT NOT NULL DEFAULT '',
+ materialName TEXT NOT NULL DEFAULT '',
+ materialKey TEXT NOT NULL DEFAULT '',
+ materialGrade TEXT NOT NULL DEFAULT '',
+ gradeKey TEXT NOT NULL DEFAULT '',
+ thickness REAL NOT NULL,
+ units TEXT NOT NULL,
+ cutType TEXT NOT NULL,
+ notes TEXT NOT NULL DEFAULT '',
+ archived INTEGER NOT NULL DEFAULT 0,
+ packageSha256 TEXT NOT NULL,
+ packageSize INTEGER NOT NULL,
+ savedAt TEXT NOT NULL,
+ savedBy TEXT NOT NULL DEFAULT '',
+ package BLOB NOT NULL
+ """;
+
+ private const string DrawingLibrarySchema = $"""
+ CREATE TABLE drawings ({DrawingColumnDefinitions}, PRIMARY KEY (id));
+ CREATE TABLE drawing_revisions ({DrawingColumnDefinitions});
+ CREATE UNIQUE INDEX ix_drawing_revisions_key ON drawing_revisions(id, revision);
+ CREATE INDEX ix_drawings_customer_name ON drawings(customerKey, nameKey);
+ CREATE INDEX ix_drawings_material ON drawings(materialKey);
+ CREATE INDEX ix_drawings_thickness ON drawings(thickness);
+ CREATE INDEX ix_drawings_savedAt ON drawings(savedAt DESC);
+ PRAGMA user_version=4;
+ """;
+
+ private const string DrawingColumns = """
+ id, revision, name, customer, materialName, materialGrade, thickness, units,
+ cutType, notes, archived, packageSha256, packageSize, savedAt, savedBy
+ """;
+
+ private const string DrawingStoredColumns = DrawingColumns + ", nameKey, customerKey, materialKey, gradeKey, package";
+
+ // Case-insensitive (ASCII) substring; the text is always a bound, escaped LIKE parameter.
+ private const string DrawingSearchPredicate = """
+ name LIKE $pattern ESCAPE '\' OR customer LIKE $pattern ESCAPE '\' OR notes LIKE $pattern ESCAPE '\'
+ """;
+
+ public DrawingLibraryRecord? GetDrawing(Guid id)
+ {
+ lock (_sync)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = $"SELECT {DrawingColumns} FROM drawings WHERE id=$id";
+ command.Parameters.AddWithValue("$id", id.ToString());
+ using var reader = command.ExecuteReader();
+ return reader.Read() ? ReadDrawing(reader) : null;
+ }
+ }
+
+ public byte[]? GetDrawingPackage(Guid id)
+ {
+ lock (_sync)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = "SELECT package FROM drawings WHERE id=$id";
+ command.Parameters.AddWithValue("$id", id.ToString());
+ return command.ExecuteScalar() as byte[];
+ }
+ }
+
+ /// One bounded page of matching drawings plus the total, read in one monitor hold.
+ public DrawingLibraryPage QueryDrawings(DrawingLibraryQuery 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("archived=0");
+ if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")");
+ if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer");
+ if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("materialKey=$material");
+ if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade");
+ if (query.Thickness is not null) conditions.Add("thickness=$thickness");
+ if (query.CutType is not null) conditions.Add("cutType=$cutType");
+ var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
+
+ lock (_sync)
+ {
+ using var count = _connection.CreateCommand();
+ count.CommandText = $"SELECT COUNT(*) FROM drawings {where}";
+ AddDrawingFilterParameters(count, query);
+ var total = checked((int)(long)count.ExecuteScalar()!);
+
+ using var command = _connection.CreateCommand();
+ var direction = query.Descending ? "DESC" : "ASC";
+ command.CommandText = $"""
+ SELECT {DrawingColumns} FROM drawings {where}
+ ORDER BY {DrawingSortColumn(query.Sort)} {direction}, id {direction}
+ LIMIT $limit OFFSET $offset
+ """;
+ AddDrawingFilterParameters(command, query);
+ command.Parameters.AddWithValue("$limit", query.Limit);
+ command.Parameters.AddWithValue("$offset", query.Offset);
+ var records = new List();
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ records.Add(ReadDrawing(reader));
+ return new DrawingLibraryPage { Items = records, Total = total, Offset = query.Offset, Limit = query.Limit };
+ }
+ }
+
+ /// Stores revision 1 under a client-allocated id; an existing id is a conflict.
+ public DrawingLibraryRecord InsertDrawing(Guid id, DrawingLibraryRecord record, byte[] package)
+ {
+ if (id == Guid.Empty) throw new ArgumentException("A drawing id is required.", nameof(id));
+ ValidateDrawing(record, package);
+ lock (_sync)
+ {
+ using var transaction = _connection.BeginTransaction();
+ if (GetDrawing(id) is { } existing)
+ throw new DrawingConflictException(existing, "A library drawing with this id already exists.");
+ WriteDrawingRevision(id, record, package, 1, archived: false, insert: true, transaction);
+ transaction.Commit();
+ return GetDrawing(id) ?? throw new InvalidOperationException("Insert did not persist.");
+ }
+ }
+
+ ///
+ /// Appends a revision with new metadata and, when is not null,
+ /// new geometry. Returns null for an unknown id; an identical save is a no-op.
+ ///
+ public DrawingLibraryRecord? UpdateDrawing(Guid id, DrawingLibraryRecord record, byte[]? package, int expectedRevision)
+ {
+ lock (_sync)
+ {
+ using var transaction = _connection.BeginTransaction();
+ var current = GetDrawing(id);
+ if (current is null) return null;
+ if (current.Revision != expectedRevision)
+ throw new DrawingConflictException(current, "The library drawing has changed. Reload before saving.");
+ if (current.Archived)
+ throw new DrawingConflictException(current, "The library drawing is archived. Restore it before saving.");
+ package ??= GetDrawingPackage(id)!;
+ ValidateDrawing(record, package);
+ if (SameDrawingMetadata(current, record) && GetDrawingPackage(id)!.AsSpan().SequenceEqual(package))
+ return current;
+
+ WriteDrawingRevision(id, record, package, checked(current.Revision + 1), archived: false, insert: false, transaction);
+ transaction.Commit();
+ return GetDrawing(id);
+ }
+ }
+
+ /// Archives or restores a drawing as a new revision. Returns null for an unknown id.
+ public DrawingLibraryRecord? SetDrawingArchived(Guid id, bool archived, int expectedRevision)
+ {
+ lock (_sync)
+ {
+ using var transaction = _connection.BeginTransaction();
+ var current = GetDrawing(id);
+ if (current is null) return null;
+ if (current.Revision != expectedRevision)
+ throw new DrawingConflictException(current, "The library drawing has changed. Reload and try again.");
+ if (current.Archived == archived)
+ throw new DrawingConflictException(current,
+ archived ? "The library drawing is already archived." : "The library drawing is not archived.");
+
+ WriteDrawingRevision(id, current, GetDrawingPackage(id)!, checked(current.Revision + 1), archived, insert: false, transaction);
+ transaction.Commit();
+ return GetDrawing(id);
+ }
+ }
+
+ public DrawingLibraryPage? DrawingHistory(Guid id, int offset, int limit)
+ {
+ if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
+ throw new ArgumentOutOfRangeException(nameof(limit));
+ lock (_sync)
+ {
+ if (GetDrawing(id) is null) return null;
+ using var count = _connection.CreateCommand();
+ count.CommandText = "SELECT COUNT(*) FROM drawing_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 {DrawingColumns} FROM drawing_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset";
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.Parameters.AddWithValue("$limit", limit);
+ command.Parameters.AddWithValue("$offset", offset);
+ var records = new List();
+ using var reader = command.ExecuteReader();
+ while (reader.Read()) records.Add(ReadDrawing(reader));
+ return new DrawingLibraryPage { Items = records, Total = total, Offset = offset, Limit = limit };
+ }
+ }
+
+ public byte[]? GetDrawingRevisionPackage(Guid id, int revision)
+ {
+ lock (_sync)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = "SELECT package FROM drawing_revisions WHERE id=$id AND revision=$revision";
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.Parameters.AddWithValue("$revision", revision);
+ return command.ExecuteScalar() as byte[];
+ }
+ }
+
+ private static void ValidateDrawing(DrawingLibraryRecord record, byte[] package)
+ {
+ var error = DrawingLibraryRules.GetMetadataError(record) ?? DrawingLibraryRules.GetPackageError(package);
+ if (error is not null)
+ throw new ArgumentException(error, nameof(record));
+ }
+
+ private static bool SameDrawingMetadata(DrawingLibraryRecord a, DrawingLibraryRecord b) =>
+ a.Name == b.Name && a.Customer == b.Customer && a.MaterialName == b.MaterialName &&
+ a.MaterialGrade == b.MaterialGrade && a.Thickness == DrawingLibraryRules.NormalizeThickness(b.Thickness) &&
+ a.Units == b.Units && a.CutType == b.CutType && a.Notes == b.Notes;
+
+ // Called only under _sync with the caller's transaction; promotion and snapshot are one write.
+ private void WriteDrawingRevision(Guid id, DrawingLibraryRecord record, byte[] package, int revision,
+ bool archived, bool insert, SqliteTransaction transaction)
+ {
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = insert
+ ? $"""
+ INSERT INTO drawings ({DrawingStoredColumns})
+ VALUES ($id, $revision, $name, $customer, $materialName, $materialGrade, $thickness, $units,
+ $cutType, $notes, $archived, $sha, $size, $savedAt, $savedBy,
+ $nameKey, $customerKey, $materialKey, $gradeKey, $package)
+ """
+ : """
+ UPDATE drawings SET revision=$revision, name=$name, customer=$customer,
+ materialName=$materialName, materialGrade=$materialGrade, thickness=$thickness,
+ units=$units, cutType=$cutType, notes=$notes, archived=$archived, packageSha256=$sha,
+ packageSize=$size, savedAt=$savedAt, savedBy=$savedBy, nameKey=$nameKey,
+ customerKey=$customerKey, materialKey=$materialKey, gradeKey=$gradeKey, package=$package
+ WHERE id=$id
+ """;
+ 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("$materialName", record.MaterialName);
+ command.Parameters.AddWithValue("$materialGrade", record.MaterialGrade);
+ command.Parameters.AddWithValue("$thickness", DrawingLibraryRules.NormalizeThickness(record.Thickness));
+ command.Parameters.AddWithValue("$units", record.Units.ToString());
+ command.Parameters.AddWithValue("$cutType", record.CutType.ToString());
+ command.Parameters.AddWithValue("$notes", record.Notes);
+ command.Parameters.AddWithValue("$archived", archived ? 1 : 0);
+ command.Parameters.AddWithValue("$sha", Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant());
+ command.Parameters.AddWithValue("$size", package.LongLength);
+ 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.Parameters.AddWithValue("$materialKey", SharedListNames.Key(record.MaterialName));
+ command.Parameters.AddWithValue("$gradeKey", SharedListNames.Key(record.MaterialGrade));
+ command.Parameters.AddWithValue("$package", package);
+ command.ExecuteNonQuery();
+
+ command.Parameters.Clear();
+ command.CommandText = $"INSERT INTO drawing_revisions ({DrawingStoredColumns}) SELECT {DrawingStoredColumns} FROM drawings WHERE id=$id";
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.ExecuteNonQuery();
+ }
+
+ private static void AddDrawingFilterParameters(SqliteCommand command, DrawingLibraryQuery query)
+ {
+ AddSearchParameter(command, query.NormalizedSearch);
+ if (SharedListNames.Key(query.Customer).Length > 0)
+ command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.Customer));
+ if (SharedListNames.Key(query.MaterialName).Length > 0)
+ command.Parameters.AddWithValue("$material", SharedListNames.Key(query.MaterialName));
+ if (SharedListNames.Key(query.MaterialGrade).Length > 0)
+ command.Parameters.AddWithValue("$grade", SharedListNames.Key(query.MaterialGrade));
+ if (query.Thickness is { } thickness)
+ command.Parameters.AddWithValue("$thickness", DrawingLibraryRules.NormalizeThickness(thickness));
+ if (query.CutType is { } cutType)
+ command.Parameters.AddWithValue("$cutType", cutType.ToString());
+ }
+
+ // Fixed allowlist: request text never reaches the ORDER BY clause.
+ private static string DrawingSortColumn(DrawingSortField field) => field switch
+ {
+ DrawingSortField.SavedAt => "savedAt",
+ DrawingSortField.Name => "name COLLATE NOCASE",
+ DrawingSortField.Customer => "customer COLLATE NOCASE",
+ DrawingSortField.MaterialName => "materialName COLLATE NOCASE",
+ DrawingSortField.MaterialGrade => "materialGrade COLLATE NOCASE",
+ DrawingSortField.Thickness => "thickness",
+ DrawingSortField.CutType => "cutType",
+ DrawingSortField.Revision => "revision",
+ _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."),
+ };
+
+ private static DrawingLibraryRecord ReadDrawing(SqliteDataReader reader) => 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),
+ };
+}
+
+/// A guarded drawing write found a different current state; nothing was changed.
+public sealed class DrawingConflictException(DrawingLibraryRecord current, string message) : Exception(message)
+{
+ public DrawingLibraryRecord Current { get; } = current;
+}
diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs
index 22d116c6..25534646 100644
--- a/OpenNest.Server/NestDatabase.cs
+++ b/OpenNest.Server/NestDatabase.cs
@@ -9,7 +9,7 @@ namespace OpenNest.Server;
/// so a PC that loses its local id mapping can still re-upload under a new id;
/// updates keep the id.
///
-public sealed class NestDatabase : IDisposable
+public sealed partial class NestDatabase : IDisposable
{
// One reentrant monitor owns each complete connection operation, including
// readers, nested Get readbacks, health checks, and disposal.
@@ -316,7 +316,7 @@ public sealed class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
- if (version > 3) throw new InvalidOperationException("Database schema is newer than this server.");
+ if (version > 4) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -346,6 +346,11 @@ public sealed class NestDatabase : IDisposable
""";
command.ExecuteNonQuery();
}
+ if (version < 4)
+ {
+ command.CommandText = DrawingLibrarySchema;
+ command.ExecuteNonQuery();
+ }
transaction.Commit();
}
}
diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs
index b78629e9..eebe89ba 100644
--- a/OpenNest.Server/Program.cs
+++ b/OpenNest.Server/Program.cs
@@ -70,7 +70,7 @@ app.MapGet("/api/nests/{id:guid}/revisions/{revision:int}/file", (Guid id, int r
app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) =>
{
- var precondition = ReadPrecondition(http.Request, out var expected);
+ var precondition = IfMatch.Read(http.Request, out var expected);
if (precondition is not null) return precondition;
var (record, file, error) = await ReadMultipart(http);
if (error is not null) return error;
@@ -87,7 +87,7 @@ app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDa
app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpRequest request, NestDatabase db) =>
{
- var error = ReadPrecondition(request, out var expected);
+ var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
NestRecord? record;
try { record = await request.ReadFromJsonAsync(); }
@@ -120,7 +120,7 @@ app.MapGet("/api/nests/deleted/{id:guid}/revisions/{revision:int}/file", (Guid i
app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, int revision, HttpRequest request, NestDatabase db) =>
{
- var error = ReadPrecondition(request, out var expected);
+ var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
if (revision < 1) return Results.BadRequest("Invalid revision.");
try
@@ -131,24 +131,13 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
-app.Run();
+app.MapDrawingLibrary();
-static IResult? ReadPrecondition(HttpRequest request, out int revision)
-{
- revision = 0;
- var values = request.Headers.IfMatch;
- if (values.Count == 0) return Results.Json("Upgrade/reload required: send If-Match with the current revision.", statusCode: 428);
- var text = values.Count == 1 ? values[0] : null;
- if (text is null || text.Length < 3 || text[0] != '"' || text[^1] != '"' ||
- !text[1..^1].All(c => c is >= '0' and <= '9') ||
- !int.TryParse(text[1..^1], out revision) || revision < 1)
- return Results.BadRequest("If-Match must contain one quoted positive integer revision.");
- return null;
-}
+app.Run();
static IResult ChangeVisibility(Guid id, bool deleted, HttpRequest request, NestDatabase db)
{
- var error = ReadPrecondition(request, out var expected);
+ var error = IfMatch.Read(request, out var expected);
if (error is not null) return error;
try
{