mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 23:11:07 -04:00
feat(server): record set releases and the nests they made
Releasing a drawing set makes one nest per material group, so a kit run spans several nests. The server now records which set revision was released, the multiplier and the nests it made, so a run can be tracked across its nests and a nest can say where it came from. The client uploads every nest first and posts the release last: a failure part-way leaves ordinary nests and no release, never a release pointing at missing nests. For the same reason a release is accepted for an older set revision or an archived set, since the nests already exist by then. The release id is client-allocated and a nest belongs to at most one release, so a retried post gets 409 with the release that already holds it. Releases are append-only and read joined to each nest's current metadata, so status and deletion are always current. The tables join schema v7, as the set tables have not shipped yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be057bf337
commit
44666b289d
14 files changed
+999
-46
No files matched your search
@@ -0,0 +1,26 @@
|
||||
using OpenNest;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>One nest of a release and the material group it was made for.</summary>
|
||||
public sealed class SetReleaseNest
|
||||
{
|
||||
public Guid NestId { get; set; }
|
||||
|
||||
public string MaterialName { get; set; } = "";
|
||||
|
||||
public string MaterialGrade { get; set; } = "";
|
||||
|
||||
/// <summary>Thickness in <see cref="Units"/>, stored rounded to four decimals.</summary>
|
||||
public double Thickness { get; set; }
|
||||
|
||||
public Units Units { get; set; } = Units.Inches;
|
||||
|
||||
public CutType CutType { get; set; } = CutType.Laser;
|
||||
|
||||
/// <summary>
|
||||
/// The nest's current metadata, deleted or not, joined by the server when it reads a
|
||||
/// release, so status and deletion are always up to date. Ignored on post.
|
||||
/// </summary>
|
||||
public NestRecord? Nest { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>One bounded page of set releases, newest first, each with its nests.</summary>
|
||||
public sealed class SetReleasePage
|
||||
{
|
||||
public IReadOnlyList<SetReleaseRecord> Items { get; init; } = Array.Empty<SetReleaseRecord>();
|
||||
|
||||
/// <summary>Number of releases of the set across all pages.</summary>
|
||||
public int Total { get; init; }
|
||||
|
||||
public int Offset { get; init; }
|
||||
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// One release of a drawing set revision: the nests made from it, one per material group,
|
||||
/// with every member quantity multiplied by <see cref="Multiplier"/>. Releases are
|
||||
/// append-only; a mistaken one is undone by deleting its nests, which then read as deleted.
|
||||
/// </summary>
|
||||
public sealed class SetReleaseRecord
|
||||
{
|
||||
/// <summary>Allocated by the client, so an ambiguous post can be retried without a duplicate.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>The set released. Taken from the route on post.</summary>
|
||||
public Guid SetId { get; set; }
|
||||
|
||||
/// <summary>The exact set revision released; it need not be the current one.</summary>
|
||||
public int SetRevision { get; set; }
|
||||
|
||||
/// <summary>The set's name at <see cref="SetRevision"/>. Server-joined; ignored on post.</summary>
|
||||
public string SetName { get; set; } = "";
|
||||
|
||||
public int Multiplier { get; set; } = 1;
|
||||
|
||||
public string Notes { get; set; } = "";
|
||||
|
||||
/// <summary>When the server stored the release. Server-computed.</summary>
|
||||
public DateTime ReleasedAt { get; set; }
|
||||
|
||||
/// <summary>Who released the set, as reported by the client.</summary>
|
||||
public string ReleasedBy { get; set; } = "";
|
||||
|
||||
/// <summary>The nests created by the release, in the order posted.</summary>
|
||||
public IReadOnlyList<SetReleaseNest> Nests { get; set; } = Array.Empty<SetReleaseNest>();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using OpenNest;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds shared by the set release client and server. Whether the set revision and
|
||||
/// every nest exist, and whether a nest already belongs to a release, is checked by the server.
|
||||
/// </summary>
|
||||
public static class SetReleaseRules
|
||||
{
|
||||
public const int MaxMultiplier = 1000;
|
||||
|
||||
/// <summary>One nest per material group, so never more nests than a set has members.</summary>
|
||||
public const int MaxNests = DrawingSetRules.MaxMembers;
|
||||
|
||||
/// <summary>The first rule <paramref name="release"/> breaks, or null when it may be posted.</summary>
|
||||
public static string? GetError(SetReleaseRecord? release)
|
||||
{
|
||||
if (release is null)
|
||||
return "Missing release.";
|
||||
if (release.SetRevision < 1)
|
||||
return "setRevision must be a positive revision.";
|
||||
if (release.Multiplier < 1 || release.Multiplier > MaxMultiplier)
|
||||
return $"multiplier must be between 1 and {MaxMultiplier}.";
|
||||
var error = DrawingLibraryRules.TextError("releasedBy", release.ReleasedBy, DrawingLibraryRules.MaxTextLength)
|
||||
?? DrawingLibraryRules.TextError("notes", release.Notes, DrawingLibraryRules.MaxNotesLength);
|
||||
if (error is not null)
|
||||
return error;
|
||||
if (release.Nests is null || release.Nests.Count == 0)
|
||||
return "nests must list at least one nest.";
|
||||
if (release.Nests.Count > MaxNests)
|
||||
return $"A release may have at most {MaxNests} nests.";
|
||||
|
||||
var seen = new HashSet<Guid>();
|
||||
for (var i = 0; i < release.Nests.Count; i++)
|
||||
{
|
||||
var nest = release.Nests[i];
|
||||
if (nest is null)
|
||||
return $"nests[{i}] is required.";
|
||||
if (nest.NestId == Guid.Empty)
|
||||
return $"nests[{i}].nestId is required.";
|
||||
if (!seen.Add(nest.NestId))
|
||||
return $"nests[{i}] repeats nest {nest.NestId}.";
|
||||
error = DrawingLibraryRules.TextError($"nests[{i}].materialName", nest.MaterialName, DrawingLibraryRules.MaxTextLength)
|
||||
?? DrawingLibraryRules.TextError($"nests[{i}].materialGrade", nest.MaterialGrade, DrawingLibraryRules.MaxTextLength)
|
||||
?? (!double.IsFinite(nest.Thickness) || nest.Thickness < 0
|
||||
? $"nests[{i}].thickness must be a finite number, zero or greater."
|
||||
: null)
|
||||
?? (!Enum.IsDefined(nest.Units) ? $"nests[{i}].units must be a supported unit name." : null)
|
||||
?? (!Enum.IsDefined(nest.CutType) ? $"nests[{i}].cutType must be Laser, Waterjet or Plasma." : null);
|
||||
if (error is not null)
|
||||
return error;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,7 @@ public sealed class DrawingLibraryDatabaseTests
|
||||
}
|
||||
|
||||
Execute(path, """
|
||||
DROP TABLE set_release_nests; DROP TABLE set_releases;
|
||||
DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions;
|
||||
DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3;
|
||||
""");
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class DrawingSetDatabaseTests
|
||||
Assert.Throws<SqliteException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
|
||||
|
||||
Assert.Equal(6L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
|
||||
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'drawing_set%'"));
|
||||
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'drawing_set%' OR name LIKE 'set_release%'"));
|
||||
Assert.Equal(before, Digest(factory.DatabasePath));
|
||||
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
|
||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
@@ -295,7 +295,7 @@ public sealed class DrawingSetDatabaseTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A real schema-v6 file with nests and a library drawing: the set tables are additive,
|
||||
/// A real schema-v6 file with nests and a library drawing: the set and release tables are additive,
|
||||
/// so dropping them restores v6 exactly. Returns the drawing's id.
|
||||
/// </summary>
|
||||
private static Guid CreateVersion6Database(string path)
|
||||
@@ -314,7 +314,10 @@ public sealed class DrawingSetDatabaseTests
|
||||
database.UpdateDrawing(drawing, edit, null, 1);
|
||||
}
|
||||
|
||||
Execute(path, "DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; PRAGMA user_version=6;");
|
||||
Execute(path, """
|
||||
DROP TABLE set_release_nests; DROP TABLE set_releases;
|
||||
DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; PRAGMA user_version=6;
|
||||
""");
|
||||
return drawing;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,4 +50,40 @@ internal static class DrawingSetFixtures
|
||||
|
||||
public static IEnumerable<(Guid, int)> MembersOf(DrawingSetRecord record) =>
|
||||
record.Members.Select(m => (m.DrawingId, m.Quantity));
|
||||
|
||||
/// <summary>Stores a draft nest directly and returns its id.</summary>
|
||||
public static Guid AddNest(NestDatabase database, string name = "Synthetic nest") =>
|
||||
database.Insert(Guid.NewGuid(), new NestRecord { Name = name, Status = NestStatus.Draft }, new byte[] { 1, 2, 3 }).Id;
|
||||
|
||||
public static async Task<Guid> AddNest(HttpClient client, string name = "Synthetic nest", Guid? id = null)
|
||||
{
|
||||
var record = new NestRecord { Id = id ?? Guid.NewGuid(), Name = name, Status = NestStatus.Draft };
|
||||
using var body = new MultipartFormDataContent
|
||||
{
|
||||
{ new StringContent(JsonSerializer.Serialize(record, Options), Encoding.UTF8, "application/json"), "metadata" },
|
||||
{ new ByteArrayContent(new byte[] { 1, 2, 3 }), "file", "synthetic.nest" },
|
||||
};
|
||||
using var response = await client.PostAsync("/api/nests", body);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return record.Id;
|
||||
}
|
||||
|
||||
/// <summary>A release of <paramref name="setRevision"/> making one nest per id, each in its own material.</summary>
|
||||
public static SetReleaseRecord Release(int setRevision, params Guid[] nests) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SetRevision = setRevision,
|
||||
Multiplier = 2,
|
||||
Notes = "Synthetic release",
|
||||
ReleasedBy = "Synthetic operator",
|
||||
Nests = nests.Select((nest, i) => new SetReleaseNest
|
||||
{
|
||||
NestId = nest,
|
||||
MaterialName = "Synthetic Steel " + i,
|
||||
MaterialGrade = "Grade A",
|
||||
Thickness = 0.25,
|
||||
Units = Units.Inches,
|
||||
CutType = CutType.Laser,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using OpenNest.Data;
|
||||
using static OpenNest.Server.Tests.DrawingLibraryFixtures;
|
||||
using static OpenNest.Server.Tests.DrawingSetFixtures;
|
||||
|
||||
namespace OpenNest.Server.Tests;
|
||||
|
||||
public sealed class SetReleaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void Release_OfAStaleRevision_IsStoredAndJoinsItsSetNameAndNests()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
var drawing = AddDrawing(database);
|
||||
var set = Set("Kit as released", members: (drawing, 1));
|
||||
database.InsertDrawingSet(set.Id, set);
|
||||
database.UpdateDrawingSet(set.Id, Set("Kit renamed later", set.Id, (drawing, 3)), 1);
|
||||
var first = AddNest(database, "Nest one");
|
||||
var second = AddNest(database, "Nest two");
|
||||
var release = Release(1, first, second);
|
||||
release.Nests[1].Thickness = 0.123456;
|
||||
release.Nests[1].Units = Units.Millimeters;
|
||||
release.Nests[1].CutType = CutType.Waterjet;
|
||||
|
||||
var stored = database.InsertSetRelease(set.Id, release)!;
|
||||
|
||||
Assert.Equal((release.Id, set.Id, 1, "Kit as released", 2), (stored.Id, stored.SetId, stored.SetRevision, stored.SetName, stored.Multiplier));
|
||||
Assert.Equal(("Synthetic release", "Synthetic operator"), (stored.Notes, stored.ReleasedBy));
|
||||
Assert.NotEqual(default, stored.ReleasedAt);
|
||||
Assert.Equal(new[] { first, second }, stored.Nests.Select(n => n.NestId));
|
||||
Assert.Equal(("Synthetic Steel 1", "Grade A", 0.1235, Units.Millimeters, CutType.Waterjet),
|
||||
(stored.Nests[1].MaterialName, stored.Nests[1].MaterialGrade, stored.Nests[1].Thickness, stored.Nests[1].Units, stored.Nests[1].CutType));
|
||||
Assert.Equal(("Nest one", NestStatus.Draft, false), (stored.Nests[0].Nest!.Name, stored.Nests[0].Nest!.Status, stored.Nests[0].Nest!.Deleted));
|
||||
Assert.Equal(release.Id, database.ReleaseForNest(second)!.Id);
|
||||
Assert.Null(database.ReleaseForNest(AddNest(database)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoinedNests_FollowStatusChangesAndDeletion()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
var set = Set();
|
||||
database.InsertDrawingSet(set.Id, set);
|
||||
var cut = AddNest(database, "Cut nest");
|
||||
var removed = AddNest(database, "Removed nest");
|
||||
database.InsertSetRelease(set.Id, Release(1, cut, removed));
|
||||
|
||||
var record = database.Get(cut)!;
|
||||
record.Status = NestStatus.HasBeenCut;
|
||||
database.UpdateMetadata(cut, record, 1);
|
||||
database.SetDeleted(removed, true, 1);
|
||||
|
||||
var nests = database.SetReleases(set.Id, 0, 100)!.Items.Single().Nests;
|
||||
Assert.Equal((NestStatus.HasBeenCut, false), (nests[0].Nest!.Status, nests[0].Nest!.Deleted));
|
||||
Assert.True(nests[1].Nest!.Deleted);
|
||||
Assert.True(database.ReleaseForNest(removed)!.Nests[1].Nest!.Deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Releases_PageNewestFirstPerSetAndAcceptAnArchivedSet()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
var set = Set();
|
||||
var other = Set("Other kit");
|
||||
database.InsertDrawingSet(set.Id, set);
|
||||
database.InsertDrawingSet(other.Id, other);
|
||||
var ids = new List<Guid>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var release = Release(1, AddNest(database));
|
||||
ids.Add(release.Id);
|
||||
database.InsertSetRelease(set.Id, release);
|
||||
Thread.Sleep(5);
|
||||
}
|
||||
database.InsertSetRelease(other.Id, Release(1, AddNest(database)));
|
||||
database.SetDrawingSetArchived(set.Id, true, 1);
|
||||
var afterArchive = Release(1, AddNest(database));
|
||||
Assert.NotNull(database.InsertSetRelease(set.Id, afterArchive));
|
||||
ids.Add(afterArchive.Id);
|
||||
|
||||
var page = database.SetReleases(set.Id, 1, 2)!;
|
||||
Assert.Equal(4, page.Total);
|
||||
Assert.Equal(new[] { ids[2], ids[1] }, page.Items.Select(r => r.Id));
|
||||
Assert.Single(database.SetReleases(other.Id, 0, 100)!.Items);
|
||||
Assert.Null(database.SetReleases(Guid.NewGuid(), 0, 100));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => database.SetReleases(set.Id, 0, 201));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refusals_LeaveNoState()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
var set = Set();
|
||||
database.InsertDrawingSet(set.Id, set);
|
||||
var free = AddNest(database);
|
||||
var taken = AddNest(database);
|
||||
var deleted = AddNest(database);
|
||||
database.SetDeleted(deleted, true, 1);
|
||||
var owner = Release(1, taken);
|
||||
database.InsertSetRelease(set.Id, owner);
|
||||
|
||||
Assert.Null(database.InsertSetRelease(Guid.NewGuid(), Release(1, free)));
|
||||
Assert.Contains("no revision 2", Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(2, free))).Message);
|
||||
Assert.Contains("does not exist", Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, Guid.NewGuid()))).Message);
|
||||
Assert.Contains("is deleted", Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, deleted))).Message);
|
||||
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1, free, free)));
|
||||
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, Release(1)));
|
||||
var noId = Release(1, free);
|
||||
noId.Id = Guid.Empty;
|
||||
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, noId));
|
||||
var tooMany = Release(1, free);
|
||||
tooMany.Multiplier = SetReleaseRules.MaxMultiplier + 1;
|
||||
Assert.Throws<ArgumentException>(() => database.InsertSetRelease(set.Id, tooMany));
|
||||
|
||||
var claimed = Assert.Throws<SetReleaseConflictException>(() => database.InsertSetRelease(set.Id, Release(1, free, taken)));
|
||||
Assert.Equal(owner.Id, claimed.Current.Id);
|
||||
var reused = Release(1, free);
|
||||
reused.Id = owner.Id;
|
||||
Assert.Equal(owner.Id, Assert.Throws<SetReleaseConflictException>(() => database.InsertSetRelease(set.Id, reused)).Current.Id);
|
||||
|
||||
Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM set_releases"));
|
||||
Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM set_release_nests"));
|
||||
Assert.Null(database.ReleaseForNest(free));
|
||||
}
|
||||
|
||||
// Code-level ownership gate, as for the set operations.
|
||||
[Theory]
|
||||
[InlineData("InsertSetRelease")]
|
||||
[InlineData("SetReleases")]
|
||||
[InlineData("ReleaseForNest")]
|
||||
public async Task Concurrency_EveryReleaseOperationWaitsForTheSameMonitor(string operation)
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
var set = Set();
|
||||
database.InsertDrawingSet(set.Id, set);
|
||||
var nest = AddNest(database);
|
||||
var next = AddNest(database);
|
||||
database.InsertSetRelease(set.Id, Release(1, nest));
|
||||
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!;
|
||||
var completed = new TaskCompletionSource<Exception?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var worker = new Thread(() => completed.SetResult(Xunit.Record.Exception(() =>
|
||||
{
|
||||
switch (operation)
|
||||
{
|
||||
case "InsertSetRelease": database.InsertSetRelease(set.Id, Release(1, next)); break;
|
||||
case "SetReleases": database.SetReleases(set.Id, 0, 100); break;
|
||||
case "ReleaseForNest": database.ReleaseForNest(nest); break;
|
||||
default: throw new InvalidOperationException($"Unknown test operation: {operation}");
|
||||
}
|
||||
})))
|
||||
{ IsBackground = true };
|
||||
|
||||
bool observedWaiting;
|
||||
bool completedWhileHeld;
|
||||
lock (sync)
|
||||
{
|
||||
worker.Start();
|
||||
observedWaiting = SpinWait.SpinUntil(() => completed.Task.IsCompleted ||
|
||||
(worker.ThreadState & ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(10));
|
||||
completedWhileHeld = completed.Task.IsCompleted;
|
||||
}
|
||||
|
||||
Assert.True(worker.Join(TimeSpan.FromSeconds(10)), "Operation did not finish after releasing the monitor.");
|
||||
Assert.True(observedWaiting, "Caller never reached the operation within the watchdog.");
|
||||
Assert.False(completedWhileHeld, operation + " bypassed the shared monitor.");
|
||||
Assert.Null(await completed.Task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concurrency_AllReleaseSqlStepsOwnTheMonitor()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||
var set = Set();
|
||||
database.InsertDrawingSet(set.Id, set);
|
||||
var nest = AddNest(database);
|
||||
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!;
|
||||
var connection = (SqliteConnection)typeof(NestDatabase)
|
||||
.GetField("_connection", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!;
|
||||
var steps = 0;
|
||||
var unownedSteps = 0;
|
||||
SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 1, _ =>
|
||||
{
|
||||
steps++;
|
||||
if (!Monitor.IsEntered(sync))
|
||||
unownedSteps++;
|
||||
return 0;
|
||||
}, null);
|
||||
try
|
||||
{
|
||||
Action[] operations =
|
||||
[
|
||||
() => database.InsertSetRelease(set.Id, Release(1, nest)),
|
||||
() => database.SetReleases(set.Id, 0, 100),
|
||||
() => database.ReleaseForNest(nest),
|
||||
];
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
var before = steps;
|
||||
operation();
|
||||
Assert.True(steps > before, "Operation did not execute the observed SQLite VM.");
|
||||
Assert.Equal(0, unownedSteps);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 0, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_PostsAndReadsReleasesWithCamelCaseEnums()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var set = await Create(client, Set());
|
||||
var first = await AddNest(client, "Nest one");
|
||||
var second = await AddNest(client, "Nest two");
|
||||
var release = Release(1, first, second);
|
||||
release.Nests[1].Units = Units.Millimeters;
|
||||
release.Nests[1].CutType = CutType.Plasma;
|
||||
|
||||
using var posted = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(release, Options)));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, posted.StatusCode);
|
||||
var json = JsonNode.Parse(await posted.Content.ReadAsStringAsync())!;
|
||||
Assert.Equal(set.Id.ToString(), json["setId"]!.GetValue<string>());
|
||||
Assert.Equal("Synthetic kit", json["setName"]!.GetValue<string>());
|
||||
Assert.Equal("millimeters", json["nests"]![1]!["units"]!.GetValue<string>());
|
||||
Assert.Equal("plasma", json["nests"]![1]!["cutType"]!.GetValue<string>());
|
||||
Assert.Equal("draft", json["nests"]![0]!["nest"]!["status"]!.GetValue<string>());
|
||||
|
||||
var page = (await client.GetFromJsonAsync<SetReleasePage>($"/api/sets/{set.Id}/releases", Options))!;
|
||||
Assert.Equal(release.Id, page.Items.Single().Id);
|
||||
Assert.Equal("Nest two", page.Items.Single().Nests[1].Nest!.Name);
|
||||
var fromNest = (await client.GetFromJsonAsync<SetReleaseRecord>($"/api/nests/{second}/release", Options))!;
|
||||
Assert.Equal((release.Id, 2), (fromNest.Id, fromNest.Multiplier));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{await AddNest(client)}/release")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{Guid.NewGuid()}/releases")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/sets/{set.Id}/releases?limit=0")).StatusCode);
|
||||
using var unknownSet = await client.PostAsync($"/api/sets/{Guid.NewGuid()}/releases",
|
||||
Json(JsonSerializer.Serialize(Release(1, await AddNest(client)), Options)));
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknownSet.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_Conflicts_ReturnTheReleaseHoldingTheIdOrNest()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var set = await Create(client, Set());
|
||||
var taken = await AddNest(client);
|
||||
var owner = Release(1, taken);
|
||||
using (await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(owner, Options)))) { }
|
||||
|
||||
var reused = Release(1, await AddNest(client));
|
||||
reused.Id = owner.Id;
|
||||
foreach (var body in new[] { Release(1, await AddNest(client), taken), reused })
|
||||
{
|
||||
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(body, Options)));
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.Equal(owner.Id, (await response.Content.ReadFromJsonAsync<SetReleaseRecord>(Options))!.Id);
|
||||
}
|
||||
|
||||
Assert.Equal(1, (await client.GetFromJsonAsync<SetReleasePage>($"/api/sets/{set.Id}/releases", Options))!.Total);
|
||||
}
|
||||
|
||||
public static TheoryData<string, string> RejectedReleases()
|
||||
{
|
||||
var valid = JsonSerializer.Serialize(Release(1, NestA), Options);
|
||||
string With(string property, JsonNode? value)
|
||||
{
|
||||
var node = JsonNode.Parse(valid)!.AsObject();
|
||||
node[property] = value;
|
||||
return node.ToJsonString();
|
||||
}
|
||||
string WithNest(string property, JsonNode? value)
|
||||
{
|
||||
var node = JsonNode.Parse(valid)!.AsObject();
|
||||
node["nests"]![0]![property] = value;
|
||||
return node.ToJsonString();
|
||||
}
|
||||
string Without(string property)
|
||||
{
|
||||
var node = JsonNode.Parse(valid)!.AsObject();
|
||||
node.Remove(property);
|
||||
return node.ToJsonString();
|
||||
}
|
||||
|
||||
return new TheoryData<string, string>
|
||||
{
|
||||
{ "empty id", With("id", Guid.Empty.ToString()) },
|
||||
{ "other set id", With("setId", Guid.NewGuid().ToString()) },
|
||||
{ "zero set revision", With("setRevision", 0) },
|
||||
{ "unknown set revision", With("setRevision", 2) },
|
||||
{ "zero multiplier", With("multiplier", 0) },
|
||||
{ "multiplier over the limit", With("multiplier", 1001) },
|
||||
{ "long notes", With("notes", new string('x', 4001)) },
|
||||
{ "null released by", With("releasedBy", null) },
|
||||
{ "missing nests", Without("nests") },
|
||||
{ "null nests", With("nests", null) },
|
||||
{ "no nests", With("nests", new JsonArray()) },
|
||||
{ "repeated nest", With("nests", new JsonArray(JsonNode.Parse(valid)!["nests"]![0]!.DeepClone(), JsonNode.Parse(valid)!["nests"]![0]!.DeepClone())) },
|
||||
{ "unknown nest", WithNest("nestId", Guid.NewGuid().ToString()) },
|
||||
{ "empty nest id", WithNest("nestId", Guid.Empty.ToString()) },
|
||||
{ "integer units", WithNest("units", 0) },
|
||||
{ "unknown units", WithNest("units", "Feet") },
|
||||
{ "combined cut type", WithNest("cutType", "Laser, Plasma") },
|
||||
{ "integer cut type", WithNest("cutType", 1) },
|
||||
{ "negative thickness", WithNest("thickness", -1) },
|
||||
{ "null material", WithNest("materialName", null) },
|
||||
{ "long grade", WithNest("materialGrade", new string('g', 201)) },
|
||||
{ "not an object", "[1]" },
|
||||
{ "malformed", "{" },
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly Guid NestA = Guid.Parse("00000000-0000-0000-0000-0000000000a1");
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RejectedReleases))]
|
||||
public async Task Api_InvalidRelease_IsRejectedWithNoState(string row, string json)
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var set = await Create(client, Set());
|
||||
await AddNest(client, id: NestA);
|
||||
|
||||
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(json));
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, row + ": " + response.StatusCode);
|
||||
Assert.Equal(0, (await client.GetFromJsonAsync<SetReleasePage>($"/api/sets/{set.Id}/releases", Options))!.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_JoinedNestInTheBody_IsIgnored()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
var set = await Create(client, Set());
|
||||
var node = JsonNode.Parse(JsonSerializer.Serialize(Release(1, await AddNest(client)), Options))!;
|
||||
node["nests"]![0]!["nest"] = new JsonObject { ["status"] = 99, ["name"] = 5 };
|
||||
|
||||
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(node.ToJsonString()));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private static object? Scalar(string path, string sql)
|
||||
{
|
||||
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return command.ExecuteScalar();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Server;
|
||||
@@ -21,6 +23,13 @@ internal static class DrawingSetRequest
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Integer enum values are refused; names are additionally checked before deserializing.
|
||||
private static readonly JsonSerializerOptions ReleaseOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) },
|
||||
};
|
||||
|
||||
public static bool TryParseQuery(IQueryCollection values, out DrawingSetQuery query, out string error)
|
||||
{
|
||||
query = new DrawingSetQuery();
|
||||
@@ -109,23 +118,12 @@ internal static class DrawingSetRequest
|
||||
return false;
|
||||
}
|
||||
|
||||
var members = root.FirstOrDefault(p => string.Equals(p.Key, "members", StringComparison.OrdinalIgnoreCase));
|
||||
if (members.Key is null)
|
||||
if (!RemoveJoined(root, "members", "drawing"))
|
||||
{
|
||||
error = "members is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (members.Value is JsonArray array)
|
||||
{
|
||||
foreach (var member in array.OfType<JsonObject>())
|
||||
{
|
||||
foreach (var key in member.Select(p => p.Key)
|
||||
.Where(k => string.Equals(k, "drawing", StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
member.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
record = root.Deserialize<DrawingSetRecord>(SetOptions) ?? record;
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -137,4 +135,91 @@ internal static class DrawingSetRequest
|
||||
error = DrawingSetRules.GetError(record) ?? "";
|
||||
return error.Length == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads and validates one release JSON object. <c>nests</c> must be present, each
|
||||
/// nest's <c>units</c> and <c>cutType</c> must be single names, and each nest's joined
|
||||
/// <c>nest</c> is ignored.
|
||||
/// </summary>
|
||||
public static bool TryReadRelease(string? json, out SetReleaseRecord release, out string error)
|
||||
{
|
||||
release = new SetReleaseRecord();
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
error = "Missing release.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonNode.Parse(json) is not JsonObject root)
|
||||
{
|
||||
error = "The release must be a JSON object.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!RemoveJoined(root, "nests", "nest"))
|
||||
{
|
||||
error = "nests is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enum converters also accept comma-separated combinations; allow single names only.
|
||||
if (Property(root, "nests") is JsonArray nests)
|
||||
{
|
||||
for (var i = 0; i < nests.Count; i++)
|
||||
{
|
||||
if (nests[i] is not JsonObject nest)
|
||||
continue;
|
||||
if (Property(nest, "units") is { } units && !IsName<Units>(units))
|
||||
{
|
||||
error = $"nests[{i}].units must be a supported unit name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Property(nest, "cutType") is { } cutType && !IsName<CutType>(cutType))
|
||||
{
|
||||
error = $"nests[{i}].cutType must be Laser, Waterjet or Plasma.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
release = root.Deserialize<SetReleaseRecord>(ReleaseOptions) ?? release;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
error = "Invalid release JSON.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = SetReleaseRules.GetError(release) ?? "";
|
||||
return error.Length == 0;
|
||||
}
|
||||
|
||||
private static JsonNode? Property(JsonObject node, string name) =>
|
||||
node.FirstOrDefault(p => string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)).Value;
|
||||
|
||||
private static bool IsName<TEnum>(JsonNode value)
|
||||
where TEnum : struct, Enum =>
|
||||
value is JsonValue text && text.TryGetValue<string>(out var name) && DrawingLibraryRequest.TryEnumName<TEnum>(name, out _);
|
||||
|
||||
// Drops the server-joined property from each object in the list, so a record read from
|
||||
// the server may be sent back; false when the list property is missing altogether.
|
||||
private static bool RemoveJoined(JsonObject root, string list, string joined)
|
||||
{
|
||||
if (!root.Any(p => string.Equals(p.Key, list, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
if (Property(root, list) is JsonArray items)
|
||||
{
|
||||
foreach (var item in items.OfType<JsonObject>())
|
||||
{
|
||||
foreach (var key in item.Select(p => p.Key)
|
||||
.Where(k => string.Equals(k, joined, StringComparison.OrdinalIgnoreCase)).ToList())
|
||||
item.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ using OpenNest.Data;
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>
|
||||
/// <c>/api/sets</c>: named, versioned lists of library drawings with quantities. Sets
|
||||
/// travel as JSON; existing-set writes follow the drawing If-Match contract (428 missing,
|
||||
/// 400 malformed, 409 stale or wrong state with the current record).
|
||||
/// <c>/api/sets</c>: named, versioned lists of library drawings with quantities, and the
|
||||
/// append-only records of their releases into nests. Both travel as JSON; existing-set
|
||||
/// writes follow the drawing If-Match contract (428 missing, 400 malformed, 409 stale or
|
||||
/// wrong state with the current record).
|
||||
/// </summary>
|
||||
internal static class DrawingSetRoutes
|
||||
{
|
||||
@@ -68,6 +69,35 @@ internal static class DrawingSetRoutes
|
||||
|
||||
app.MapPost("/api/sets/{id:guid}/restore", (Guid id, HttpRequest request, NestDatabase db) =>
|
||||
SetArchived(id, false, request, db));
|
||||
|
||||
// Posted after every nest is uploaded; the client allocates the release id.
|
||||
app.MapPost("/api/sets/{id:guid}/releases", async (Guid id, HttpRequest request, NestDatabase db) =>
|
||||
{
|
||||
var (release, error) = await ReadRelease(request);
|
||||
if (error is not null) return error;
|
||||
if (release!.Id == Guid.Empty)
|
||||
return Results.BadRequest("id is required: allocate it before posting the release.");
|
||||
if (release.SetId != Guid.Empty && release.SetId != id)
|
||||
return Results.BadRequest("Release setId does not match the route.");
|
||||
try
|
||||
{
|
||||
var stored = db.InsertSetRelease(id, release);
|
||||
return stored is null ? Results.NotFound() : Results.Ok(stored);
|
||||
}
|
||||
catch (SetReleaseConflictException conflict)
|
||||
{
|
||||
return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
catch (ArgumentException invalid) { return Results.BadRequest(invalid.Message); }
|
||||
});
|
||||
|
||||
app.MapGet("/api/sets/{id:guid}/releases", (Guid id, HttpRequest request, NestDatabase db) =>
|
||||
!DrawingLibraryRequest.TryReadPaging(request.Query, out var offset, out var limit, out var error)
|
||||
? Results.BadRequest(error)
|
||||
: db.SetReleases(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound());
|
||||
|
||||
app.MapGet("/api/nests/{id:guid}/release", (Guid id, NestDatabase db) =>
|
||||
db.ReleaseForNest(id) is { } release ? Results.Ok(release) : Results.NotFound());
|
||||
}
|
||||
|
||||
private static IResult SetArchived(Guid id, bool archived, HttpRequest request, NestDatabase db)
|
||||
@@ -86,11 +116,29 @@ internal static class DrawingSetRoutes
|
||||
Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
|
||||
|
||||
private static async Task<(DrawingSetRecord? Record, IResult? Error)> ReadSet(HttpRequest request)
|
||||
{
|
||||
var (json, error) = await ReadJson(request, "set");
|
||||
if (error is not null) return (null, error);
|
||||
return DrawingSetRequest.TryReadSet(json, out var record, out var invalid)
|
||||
? (record, null)
|
||||
: (null, Results.BadRequest(invalid));
|
||||
}
|
||||
|
||||
private static async Task<(SetReleaseRecord? Release, IResult? Error)> ReadRelease(HttpRequest request)
|
||||
{
|
||||
var (json, error) = await ReadJson(request, "release");
|
||||
if (error is not null) return (null, error);
|
||||
return DrawingSetRequest.TryReadRelease(json, out var release, out var invalid)
|
||||
? (release, null)
|
||||
: (null, Results.BadRequest(invalid));
|
||||
}
|
||||
|
||||
private static async Task<(string? Json, IResult? Error)> ReadJson(HttpRequest request, string what)
|
||||
{
|
||||
if (!request.HasJsonContentType())
|
||||
return (null, Results.BadRequest("Expected an application/json set."));
|
||||
return (null, Results.BadRequest($"Expected an application/json {what}."));
|
||||
if (request.ContentLength > DrawingSetRules.MaxBodyBytes)
|
||||
return (null, Results.BadRequest("The set is too large."));
|
||||
return (null, Results.BadRequest($"The {what} is too large."));
|
||||
|
||||
// Bounded read: a body without a length, or longer than it claims, stops at the limit.
|
||||
using var body = new MemoryStream();
|
||||
@@ -99,13 +147,10 @@ internal static class DrawingSetRoutes
|
||||
while ((read = await request.Body.ReadAsync(chunk, request.HttpContext.RequestAborted)) > 0)
|
||||
{
|
||||
if (body.Length + read > DrawingSetRules.MaxBodyBytes)
|
||||
return (null, Results.BadRequest("The set is too large."));
|
||||
return (null, Results.BadRequest($"The {what} is too large."));
|
||||
body.Write(chunk, 0, read);
|
||||
}
|
||||
|
||||
var json = Encoding.UTF8.GetString(body.GetBuffer(), 0, (int)body.Length);
|
||||
return DrawingSetRequest.TryReadSet(json, out var record, out var error)
|
||||
? (record, null)
|
||||
: (null, Results.BadRequest(error));
|
||||
return (Encoding.UTF8.GetString(body.GetBuffer(), 0, (int)body.Length), null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Set release storage. <c>set_releases</c> records which set revision was released and
|
||||
/// how many times over; <c>set_release_nests</c> the nests it made, in order, with the
|
||||
/// material group each was made for. A nest belongs to at most one release. Releases are
|
||||
/// append-only and read joined to each nest's current metadata.
|
||||
/// </summary>
|
||||
public sealed partial class NestDatabase
|
||||
{
|
||||
// Part of the v7 set schema.
|
||||
private const string SetReleaseTables = """
|
||||
CREATE TABLE set_releases (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
setId TEXT NOT NULL,
|
||||
setRevision INTEGER NOT NULL,
|
||||
multiplier INTEGER NOT NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
releasedAt TEXT NOT NULL,
|
||||
releasedBy TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY (setId, setRevision) REFERENCES drawing_set_revisions(id, revision));
|
||||
CREATE TABLE set_release_nests (
|
||||
releaseId TEXT NOT NULL REFERENCES set_releases(id),
|
||||
position INTEGER NOT NULL,
|
||||
nestId TEXT NOT NULL REFERENCES nests(id),
|
||||
materialName TEXT NOT NULL DEFAULT '',
|
||||
materialGrade TEXT NOT NULL DEFAULT '',
|
||||
thickness REAL NOT NULL,
|
||||
units TEXT NOT NULL,
|
||||
cutType TEXT NOT NULL,
|
||||
PRIMARY KEY (releaseId, position));
|
||||
CREATE INDEX ix_set_releases_set ON set_releases(setId, releasedAt DESC);
|
||||
CREATE UNIQUE INDEX ix_set_release_nests_nest ON set_release_nests(nestId);
|
||||
""";
|
||||
|
||||
private const string ReleaseSelect = """
|
||||
SELECT x.id, x.setId, x.setRevision, x.multiplier, x.notes, x.releasedAt, x.releasedBy, COALESCE(v.name, '')
|
||||
FROM set_releases x LEFT JOIN drawing_set_revisions v ON v.id = x.setId AND v.revision = x.setRevision
|
||||
""";
|
||||
|
||||
private static readonly string JoinedNestColumns =
|
||||
string.Join(", ", RecordColumns.Split(',').Select(column => "n." + column.Trim()));
|
||||
|
||||
/// <summary>
|
||||
/// Records a release of <paramref name="setId"/>. Returns null for an unknown set. The
|
||||
/// set revision must exist but need not be current, and the set may be archived: the
|
||||
/// nests already exist, so a release is never refused for a state the operator cannot fix.
|
||||
/// </summary>
|
||||
public SetReleaseRecord? InsertSetRelease(Guid setId, SetReleaseRecord release)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(release);
|
||||
if (release.Id == Guid.Empty) throw new ArgumentException("A release id is required.");
|
||||
var error = SetReleaseRules.GetError(release);
|
||||
if (error is not null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
if (GetDrawingSet(setId) is null) return null;
|
||||
if (ReadRelease(release.Id) is { } existing)
|
||||
throw new SetReleaseConflictException(existing, "A release with this id already exists.");
|
||||
using (var command = _connection.CreateCommand())
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = "SELECT 1 FROM drawing_set_revisions WHERE id=$id AND revision=$revision";
|
||||
command.Parameters.AddWithValue("$id", setId.ToString());
|
||||
command.Parameters.AddWithValue("$revision", release.SetRevision);
|
||||
if (command.ExecuteScalar() is null)
|
||||
throw new ArgumentException($"The set has no revision {release.SetRevision}.");
|
||||
}
|
||||
|
||||
foreach (var nest in release.Nests)
|
||||
{
|
||||
if (Get(nest.NestId) is null)
|
||||
{
|
||||
throw new ArgumentException(Get(nest.NestId, deleted: true) is null
|
||||
? $"Nest {nest.NestId} does not exist."
|
||||
: $"Nest {nest.NestId} is deleted.");
|
||||
}
|
||||
|
||||
if (ReleaseForNest(nest.NestId) is { } owner)
|
||||
throw new SetReleaseConflictException(owner, $"Nest {nest.NestId} already belongs to a release.");
|
||||
}
|
||||
|
||||
WriteRelease(setId, release, transaction);
|
||||
transaction.Commit();
|
||||
return ReadRelease(release.Id) ?? throw new InvalidOperationException("Release did not persist.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One page of the set's releases, newest first, or null for an unknown set.</summary>
|
||||
public SetReleasePage? SetReleases(Guid setId, int offset, int limit)
|
||||
{
|
||||
if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
|
||||
throw new ArgumentOutOfRangeException(nameof(limit));
|
||||
lock (_sync)
|
||||
{
|
||||
if (GetDrawingSet(setId) is null) return null;
|
||||
using var count = _connection.CreateCommand();
|
||||
count.CommandText = "SELECT COUNT(*) FROM set_releases WHERE setId=$id";
|
||||
count.Parameters.AddWithValue("$id", setId.ToString());
|
||||
var total = checked((int)(long)count.ExecuteScalar()!);
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
{ReleaseSelect} WHERE x.setId=$id
|
||||
ORDER BY x.releasedAt DESC, x.id DESC LIMIT $limit OFFSET $offset
|
||||
""";
|
||||
command.Parameters.AddWithValue("$id", setId.ToString());
|
||||
command.Parameters.AddWithValue("$limit", limit);
|
||||
command.Parameters.AddWithValue("$offset", offset);
|
||||
var releases = ReadReleases(command);
|
||||
foreach (var release in releases)
|
||||
release.Nests = ReadReleaseNests(release.Id);
|
||||
return new SetReleasePage { Items = releases, Total = total, Offset = offset, Limit = limit };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The release a nest, deleted or not, came from, or null when it came from none.</summary>
|
||||
public SetReleaseRecord? ReleaseForNest(Guid nestId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = "SELECT releaseId FROM set_release_nests WHERE nestId=$id";
|
||||
command.Parameters.AddWithValue("$id", nestId.ToString());
|
||||
return command.ExecuteScalar() is string releaseId ? ReadRelease(Guid.Parse(releaseId)) : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Called only under _sync with the caller's transaction.
|
||||
private void WriteRelease(Guid setId, SetReleaseRecord release, SqliteTransaction transaction)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = """
|
||||
INSERT INTO set_releases (id, setId, setRevision, multiplier, notes, releasedAt, releasedBy)
|
||||
VALUES ($id, $setId, $setRevision, $multiplier, $notes, $releasedAt, $releasedBy)
|
||||
""";
|
||||
command.Parameters.AddWithValue("$id", release.Id.ToString());
|
||||
command.Parameters.AddWithValue("$setId", setId.ToString());
|
||||
command.Parameters.AddWithValue("$setRevision", release.SetRevision);
|
||||
command.Parameters.AddWithValue("$multiplier", release.Multiplier);
|
||||
command.Parameters.AddWithValue("$notes", release.Notes);
|
||||
command.Parameters.AddWithValue("$releasedAt", DateTime.Now.ToString("o"));
|
||||
command.Parameters.AddWithValue("$releasedBy", release.ReleasedBy);
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
command.Parameters.Clear();
|
||||
command.CommandText = """
|
||||
INSERT INTO set_release_nests (releaseId, position, nestId, materialName, materialGrade, thickness, units, cutType)
|
||||
VALUES ($id, $position, $nest, $materialName, $materialGrade, $thickness, $units, $cutType)
|
||||
""";
|
||||
command.Parameters.AddWithValue("$id", release.Id.ToString());
|
||||
var position = command.Parameters.Add("$position", SqliteType.Integer);
|
||||
var nestId = command.Parameters.Add("$nest", SqliteType.Text);
|
||||
var materialName = command.Parameters.Add("$materialName", SqliteType.Text);
|
||||
var materialGrade = command.Parameters.Add("$materialGrade", SqliteType.Text);
|
||||
var thickness = command.Parameters.Add("$thickness", SqliteType.Real);
|
||||
var units = command.Parameters.Add("$units", SqliteType.Text);
|
||||
var cutType = command.Parameters.Add("$cutType", SqliteType.Text);
|
||||
for (var i = 0; i < release.Nests.Count; i++)
|
||||
{
|
||||
var nest = release.Nests[i];
|
||||
position.Value = i;
|
||||
nestId.Value = nest.NestId.ToString();
|
||||
materialName.Value = nest.MaterialName;
|
||||
materialGrade.Value = nest.MaterialGrade;
|
||||
thickness.Value = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
|
||||
units.Value = nest.Units.ToString();
|
||||
cutType.Value = nest.CutType.ToString();
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
// Called only under _sync.
|
||||
private SetReleaseRecord? ReadRelease(Guid releaseId)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = $"{ReleaseSelect} WHERE x.id=$id";
|
||||
command.Parameters.AddWithValue("$id", releaseId.ToString());
|
||||
var release = ReadReleases(command).FirstOrDefault();
|
||||
if (release is not null)
|
||||
release.Nests = ReadReleaseNests(release.Id);
|
||||
return release;
|
||||
}
|
||||
|
||||
private static List<SetReleaseRecord> ReadReleases(SqliteCommand command)
|
||||
{
|
||||
var releases = new List<SetReleaseRecord>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
releases.Add(new SetReleaseRecord
|
||||
{
|
||||
Id = Guid.Parse(reader.GetString(0)),
|
||||
SetId = Guid.Parse(reader.GetString(1)),
|
||||
SetRevision = reader.GetInt32(2),
|
||||
Multiplier = reader.GetInt32(3),
|
||||
Notes = reader.GetString(4),
|
||||
ReleasedAt = DateTime.Parse(reader.GetString(5)),
|
||||
ReleasedBy = reader.GetString(6),
|
||||
SetName = reader.GetString(7),
|
||||
});
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
// Called only under _sync. Each nest joined to its current metadata, deleted or not.
|
||||
private List<SetReleaseNest> ReadReleaseNests(Guid releaseId)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
SELECT r.nestId, r.materialName, r.materialGrade, r.thickness, r.units, r.cutType, {JoinedNestColumns}
|
||||
FROM set_release_nests r LEFT JOIN nests n ON n.id = r.nestId
|
||||
WHERE r.releaseId=$id ORDER BY r.position
|
||||
""";
|
||||
command.Parameters.AddWithValue("$id", releaseId.ToString());
|
||||
var nests = new List<SetReleaseNest>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
nests.Add(new SetReleaseNest
|
||||
{
|
||||
NestId = Guid.Parse(reader.GetString(0)),
|
||||
MaterialName = reader.GetString(1),
|
||||
MaterialGrade = reader.GetString(2),
|
||||
Thickness = reader.GetDouble(3),
|
||||
Units = Enum.Parse<Units>(reader.GetString(4)),
|
||||
CutType = Enum.Parse<CutType>(reader.GetString(5)),
|
||||
Nest = reader.IsDBNull(6) ? null : ReadRecord(reader, first: 6),
|
||||
});
|
||||
}
|
||||
return nests;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A release post collided with an existing release; nothing was changed.</summary>
|
||||
public sealed class SetReleaseConflictException(SetReleaseRecord current, string message) : Exception(message)
|
||||
{
|
||||
/// <summary>The release that already holds the id or the nest.</summary>
|
||||
public SetReleaseRecord Current { get; } = current;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ public sealed partial class NestDatabase
|
||||
CREATE INDEX ix_drawing_sets_name ON drawing_sets(customerKey, nameKey);
|
||||
CREATE INDEX ix_drawing_sets_savedAt ON drawing_sets(savedAt DESC);
|
||||
CREATE INDEX ix_drawing_set_members_drawing ON drawing_set_members(drawingId);
|
||||
{SetReleaseTables}
|
||||
PRAGMA user_version=7;
|
||||
""";
|
||||
|
||||
|
||||
@@ -475,28 +475,29 @@ public sealed partial class NestDatabase : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static NestRecord ReadRecord(SqliteDataReader reader)
|
||||
// Reads RecordColumns in order from column `first` on; a join may put other columns before them.
|
||||
private static NestRecord ReadRecord(SqliteDataReader reader, int first = 0)
|
||||
{
|
||||
return new NestRecord
|
||||
{
|
||||
Id = Guid.Parse(reader.GetString(0)),
|
||||
Name = reader.GetString(1),
|
||||
Customer = reader.GetString(2),
|
||||
DateCreated = DateTime.Parse(reader.GetString(3)),
|
||||
DateModified = DateTime.Parse(reader.GetString(4)),
|
||||
Material = reader.GetString(5),
|
||||
Thickness = reader.GetDouble(6),
|
||||
Status = Enum.TryParse<NestStatus>(reader.GetString(7), true, out var s)
|
||||
Id = Guid.Parse(reader.GetString(first)),
|
||||
Name = reader.GetString(first + 1),
|
||||
Customer = reader.GetString(first + 2),
|
||||
DateCreated = DateTime.Parse(reader.GetString(first + 3)),
|
||||
DateModified = DateTime.Parse(reader.GetString(first + 4)),
|
||||
Material = reader.GetString(first + 5),
|
||||
Thickness = reader.GetDouble(first + 6),
|
||||
Status = Enum.TryParse<NestStatus>(reader.GetString(first + 7), true, out var s)
|
||||
? s : NestStatus.Quote,
|
||||
PlateCount = (int)reader.GetInt64(8),
|
||||
PartCount = (int)reader.GetInt64(9),
|
||||
Comments = reader.GetString(10),
|
||||
MadeBy = reader.GetString(11),
|
||||
FileSize = reader.GetInt64(12),
|
||||
SavedAt = DateTime.Parse(reader.GetString(13)),
|
||||
Revision = reader.GetInt32(14),
|
||||
Deleted = reader.GetInt32(15) != 0,
|
||||
RestoredFromRevision = reader.IsDBNull(16) ? null : reader.GetInt32(16),
|
||||
PlateCount = (int)reader.GetInt64(first + 8),
|
||||
PartCount = (int)reader.GetInt64(first + 9),
|
||||
Comments = reader.GetString(first + 10),
|
||||
MadeBy = reader.GetString(first + 11),
|
||||
FileSize = reader.GetInt64(first + 12),
|
||||
SavedAt = DateTime.Parse(reader.GetString(first + 13)),
|
||||
Revision = reader.GetInt32(first + 14),
|
||||
Deleted = reader.GetInt32(first + 15) != 0,
|
||||
RestoredFromRevision = reader.IsDBNull(first + 16) ? null : reader.GetInt32(first + 16),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+39
-3
@@ -374,12 +374,48 @@ save, so a record read from the server may be sent back as it is.
|
||||
and the refusal of unknown, repeated or out-of-range parameters follow the
|
||||
drawing query.
|
||||
|
||||
### Set releases
|
||||
|
||||
A set is never added to a nest, because a nest is one material. It is
|
||||
**released**: the client makes one nest per material group with every quantity
|
||||
multiplied, uploads each nest, and only then posts a **release record** naming
|
||||
the set revision and the nests. A failure part-way leaves ordinary nests and no
|
||||
release, and nothing is rolled back silently.
|
||||
|
||||
`OpenNest.Data.SetReleaseRecord` fields: `id` (client-allocated, so an
|
||||
ambiguous post can be retried), `setId` (from the route; a body value must
|
||||
match it), `setRevision`, `multiplier`, `notes`, `releasedBy`, the
|
||||
server-computed `releasedAt`, `setName` (the set's name at that revision) and
|
||||
`nests`, in order, each `{ nestId, materialName, materialGrade, thickness, units,
|
||||
cutType, nest }`. The material fields are the group the nest was made for;
|
||||
`nest` is the nest's current metadata (deleted or not), joined on every read so
|
||||
status and deletion are always current, and ignored on post.
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/sets/{id}/releases` | JSON release with a nonempty `id` | The stored release; 404 for an unknown set; 409 with the release that already holds the id or one of the nests |
|
||||
| GET | `/api/sets/{id}/releases?offset=0&limit=100` | — | Release page with nests, newest first; limit 1–200 |
|
||||
| GET | `/api/nests/{id}/release` | — | The release the nest (deleted or not) came from, or 404 |
|
||||
|
||||
- Releases are append-only: there is no edit or delete. A mistaken release is
|
||||
undone by deleting its nests, which then read as `deleted`.
|
||||
- `setRevision` must be a revision of the set but need not be the current one,
|
||||
and the set may be archived: the nests already exist by the time the release
|
||||
is posted, so it is never refused for a state the operator cannot fix.
|
||||
- Each nest belongs to at most one release (a unique index enforces it).
|
||||
- Validation (400, no state change): `multiplier` 1–1000; notes at most 4000
|
||||
and releasedBy at most 200 characters; `nests` present, 1–500 entries, no
|
||||
nest twice, each an existing, not deleted nest with material name and grade
|
||||
at most 200 characters, `thickness` finite and at least 0 (stored rounded to 4
|
||||
decimals), and `units` and `cutType` single enum names.
|
||||
|
||||
Schema v7 adds `drawing_set_revisions` (every revision's details and archived
|
||||
flag), `drawing_set_members` (each revision's members by position, unique per
|
||||
drawing) and `drawing_sets` (each set's current revision). Upgrading from v6
|
||||
only creates these tables; every other table is untouched. A server older than
|
||||
drawing), `drawing_sets` (each set's current revision), `set_releases` and
|
||||
`set_release_nests`. Upgrading from v6 only creates these tables; every other
|
||||
table is untouched. A server older than
|
||||
v7 refuses to open a v7 database, so rolling back needs the pre-upgrade backup.
|
||||
The stopped-volume `tar` backup includes the set tables; `nest_manifest.py` and
|
||||
The stopped-volume `tar` backup includes the set and release tables; `nest_manifest.py` and
|
||||
`restore_check` do not verify them yet.
|
||||
|
||||
## Storage
|
||||
|
||||
Reference in new issue
Block a user