feat(server): release several drawing sets at once, merge into drafts, and undo

A release can now cover several sets, each with its own kit count, so parts
from different kits share sheets. It can also merge into an existing draft
nest instead of always creating one. Schema v9 replaces the single-set
release tables: releases, their set lines, their nests (created or merged,
with the nest revision before and after) and the drawing quantities each set
added to each nest, so a kit stays traceable once kits share a nest. The v8
migration turns each existing release into one set line with created nests.

Undo runs on the server in one transaction: created nests are soft-deleted
and merged nests restored to their pre-merge revision, but only while each
still holds what the release wrote. Otherwise nothing changes and the
changed nests are listed, so the operator can skip them. A nest restored to
that revision counts as unchanged, which lets releases sharing a nest be
undone newest first.

The old /api/sets/{id}/releases and /api/nests/{id}/release routes are
replaced by /api/releases; no tagged desktop used them. The desktop still
releases one set at a time until the release dialog is reworked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-21 10:25:52 -04:00
1 parent 19809c61d0
commit 28db6ae807
34 files changed
+1684 -519

No files matched your search

+8 -6
View File
@@ -39,13 +39,15 @@ public interface IDrawingSetStore
Task<DrawingSetRecord> RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default);
/// <summary>Records a release whose nests already exist, under the caller-allocated <c>release.Id</c>.</summary>
Task<SetReleaseRecord> PostReleaseAsync(Guid setId, SetReleaseRecord release,
CancellationToken cancellationToken = default);
Task<SetReleaseRecord> PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default);
/// <summary>One page of the set's releases with their nests, newest first, or null for an unknown set.</summary>
Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0,
/// <summary>One release with its sets and nests, or null for an unknown id.</summary>
Task<SetReleaseRecord?> GetReleaseAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>One page of releases with their sets and nests, newest first, optionally only those including a set or a nest.</summary>
Task<SetReleasePage> QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default);
/// <summary>The release a nest came from, or null when it came from none (or is unknown).</summary>
Task<SetReleaseRecord?> GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default);
/// <summary>Undoes a release, all or nothing; a refusal raises <see cref="SetReleaseUndoConflictException"/>.</summary>
Task<SetReleaseRecord> UndoReleaseAsync(Guid id, SetReleaseUndoRequest request, CancellationToken cancellationToken = default);
}
+110 -43
View File
@@ -9,7 +9,8 @@ using System.Text.Json.Serialization;
namespace OpenNest.Data;
/// <summary>
/// Talks to the drawing sets on the nest server (<c>/api/sets</c>). Sets and releases
/// Talks to the drawing sets and their releases on the nest server (<c>/api/sets</c>,
/// <c>/api/releases</c>). Sets and releases
/// travel as JSON and are checked against <see cref="DrawingSetRules"/> and
/// <see cref="SetReleaseRules"/> before sending. A server without sets raises
/// <see cref="ServerTooOldException"/>; other failures surface as <see cref="IOException"/>.
@@ -18,6 +19,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
{
private const string Feature = "drawing sets";
private const string ReleaseFeature = "releasing several drawing sets at once";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -153,72 +156,105 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
/// <summary>
/// Records a release under <c>release.Id</c>, allocated by the caller so a post whose
/// reply was lost can be sent again. A release already holding that id, or one of the
/// nests, raises <see cref="SetReleasePostConflictException"/> with that release.
/// reply was lost can be sent again. A release already holding that id, or one of its
/// created nests, raises <see cref="SetReleasePostConflictException"/> with that release.
/// </summary>
public async Task<SetReleaseRecord> PostReleaseAsync(Guid setId, SetReleaseRecord release,
CancellationToken cancellationToken = default)
public async Task<SetReleaseRecord> PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(release);
ValidateId(setId);
if (release.Id == Guid.Empty) throw new ArgumentException("A release id is required.", nameof(release));
if (release.SetId != Guid.Empty && release.SetId != setId)
throw new ArgumentException("The release names a different set.", nameof(release));
var error = SetReleaseRules.GetError(release);
if (error is not null)
throw new ArgumentException(error, nameof(release));
using var response = await _httpClient.PostAsync(Url($"api/sets/{setId}/releases"),
JsonContent.Create(ForPost(release, setId), options: JsonOptions), cancellationToken);
using var response = await _httpClient.PostAsync(Url("api/releases"),
JsonContent.Create(ForPost(release), options: JsonOptions), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
await ThrowIfServerTooOld(cancellationToken);
throw new IOException($"Drawing set {setId} no longer exists on the nest server.");
}
throw new ServerTooOldException(ReleaseFeature);
if (response.StatusCode == HttpStatusCode.Conflict)
throw new SetReleasePostConflictException(await ReadRelease(response, cancellationToken));
await EnsureSuccess(response, $"record the release of drawing set {setId}", cancellationToken);
await EnsureSuccess(response, "record the release", cancellationToken);
var stored = await ReadRelease(response, cancellationToken);
if (stored.Id != release.Id || stored.SetId != setId || stored.SetRevision != release.SetRevision
|| !stored.Nests.Select(n => n?.NestId).SequenceEqual(release.Nests.Select(n => (Guid?)n.NestId)))
if (stored.Id != release.Id
|| !stored.Sets.Select(s => (s?.SetId, s?.SetRevision, s?.Quantity))
.SequenceEqual(release.Sets.Select(s => ((Guid?)s.SetId, (int?)s.SetRevision, (int?)s.Quantity)))
|| !stored.Nests.Select(n => (n?.NestId, n?.Kind, n?.RevisionAfter))
.SequenceEqual(release.Nests.Select(n => ((Guid?)n.NestId, (SetReleaseNestKind?)n.Kind, (int?)n.RevisionAfter))))
throw new IOException("The nest server recorded a different release than was posted.");
return stored;
}
public async Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
public async Task<SetReleaseRecord?> GetReleaseAsync(Guid id, CancellationToken cancellationToken = default)
{
ValidateId(setId);
ValidatePaging(offset, limit);
using var response = await _httpClient.GetAsync(Url($"api/sets/{setId}/releases?{Paging(offset, limit)}"), cancellationToken);
if (id == Guid.Empty) throw new ArgumentException("A release id is required.", nameof(id));
using var response = await _httpClient.GetAsync(Url($"api/releases/{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
await ThrowIfServerTooOld(cancellationToken);
await ThrowIfReleasesUnsupported(cancellationToken);
return null;
}
await EnsureSuccess(response, $"read the releases of drawing set {setId}", cancellationToken);
await EnsureSuccess(response, $"read release {id}", cancellationToken);
var release = await ReadRelease(response, cancellationToken);
if (release.Id != id)
throw new IOException("Nest server returned a different release.");
return release;
}
public async Task<SetReleasePage> QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
{
if (setId == Guid.Empty) throw new ArgumentException("A drawing set id is required.", nameof(setId));
if (nestId == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(nestId));
ValidatePaging(offset, limit);
var query = Paging(offset, limit);
if (setId is not null) query += $"&setId={setId}";
if (nestId is not null) query += $"&nestId={nestId}";
using var response = await _httpClient.GetAsync(Url($"api/releases/query?{query}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(ReleaseFeature);
await EnsureSuccess(response, "read releases", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<SetReleasePage>(JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty release page.");
if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|| page.Items.Any(item => item is null || item.SetId != setId || item.Nests is null))
|| page.Items.Any(item => item is null || item.Sets is null || item.Nests is null
|| (setId is not null && item.Sets.All(s => s?.SetId != setId))
|| (nestId is not null && item.Nests.All(n => n?.NestId != nestId))))
throw new IOException("Nest server returned an invalid or unbounded release page.");
return page;
}
public async Task<SetReleaseRecord?> GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default)
/// <summary>
/// Undoes a release on the server, all or nothing. A refusal raises
/// <see cref="SetReleaseUndoConflictException"/>: the release was undone before, or nests
/// not skipped have changed since it (listed, so the caller can skip them and try again).
/// </summary>
public async Task<SetReleaseRecord> UndoReleaseAsync(Guid id, SetReleaseUndoRequest request,
CancellationToken cancellationToken = default)
{
if (nestId == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(nestId));
using var response = await _httpClient.GetAsync(Url($"api/nests/{nestId}/release"), cancellationToken);
if (id == Guid.Empty) throw new ArgumentException("A release id is required.", nameof(id));
var error = SetReleaseRules.GetUndoError(request);
if (error is not null)
throw new ArgumentException(error, nameof(request));
using var response = await _httpClient.PostAsync(Url($"api/releases/{id}/undo"),
JsonContent.Create(request, options: JsonOptions), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
await ThrowIfServerTooOld(cancellationToken);
return null;
await ThrowIfReleasesUnsupported(cancellationToken);
throw new IOException($"Release {id} no longer exists on the nest server.");
}
await EnsureSuccess(response, $"read the release of nest {nestId}", cancellationToken);
var release = await ReadRelease(response, cancellationToken);
if (release.Nests.All(n => n?.NestId != nestId))
throw new IOException("Nest server returned a release that does not contain the nest.");
return release;
if (response.StatusCode == HttpStatusCode.Conflict)
{
var conflict = await response.Content.ReadFromJsonAsync<SetReleaseUndoConflict>(JsonOptions, cancellationToken);
if (conflict?.Release is null || conflict.ChangedNests is null)
throw new IOException("Nest server refused the undo without saying why.");
throw new SetReleaseUndoConflictException(conflict);
}
await EnsureSuccess(response, $"undo release {id}", cancellationToken);
var undone = await ReadRelease(response, cancellationToken);
if (undone.Id != id || !undone.IsUndone)
throw new IOException("Nest server did not report the release as undone.");
return undone;
}
private async Task<DrawingSetRecord> SetArchivedAsync(Guid id, int expectedRevision, string action,
@@ -290,6 +326,14 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
}
// A 404 for one set or nest is ambiguous; the query route exists on every set-aware server.
// A server from before v9 has no /api/releases at all, so a 404 there is not "unknown release".
private async Task ThrowIfReleasesUnsupported(CancellationToken cancellationToken)
{
using var probe = await _httpClient.GetAsync(Url("api/releases/query?limit=1"), cancellationToken);
if (probe.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(ReleaseFeature);
}
private async Task ThrowIfServerTooOld(CancellationToken cancellationToken)
{
using var probe = await _httpClient.GetAsync(Url("api/sets/query?limit=1"), cancellationToken);
@@ -321,22 +365,30 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
Members = record.Members.Select(m => new DrawingSetMember { DrawingId = m.DrawingId, Quantity = m.Quantity }).ToList(),
};
private static SetReleaseRecord ForPost(SetReleaseRecord release, Guid setId) => new()
// Joined and server-computed values are ignored by the server; leaving them out keeps the body small.
private static SetReleaseRecord ForPost(SetReleaseRecord release) => new()
{
Id = release.Id,
SetId = setId,
SetRevision = release.SetRevision,
Multiplier = release.Multiplier,
Notes = release.Notes,
ReleasedBy = release.ReleasedBy,
Sets = release.Sets.Select(s => new SetReleaseLine
{
SetId = s.SetId,
SetRevision = s.SetRevision,
Quantity = s.Quantity,
}).ToList(),
Nests = release.Nests.Select(n => new SetReleaseNest
{
NestId = n.NestId,
Kind = n.Kind,
RevisionBefore = n.RevisionBefore,
RevisionAfter = n.RevisionAfter,
MaterialName = n.MaterialName,
MaterialGrade = n.MaterialGrade,
Thickness = n.Thickness,
Units = n.Units,
CutType = n.CutType,
Items = n.Items,
}).ToList(),
};
@@ -390,7 +442,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
private static async Task<SetReleaseRecord> ReadRelease(HttpResponseMessage response, CancellationToken cancellationToken)
{
var release = await response.Content.ReadFromJsonAsync<SetReleaseRecord>(JsonOptions, cancellationToken);
if (release is null || release.Nests is null)
if (release is null || release.Sets is null || release.Nests is null)
throw new IOException("Nest server returned an empty set release record.");
return release;
}
@@ -439,13 +491,14 @@ public sealed class DrawingSetSaveConflictException : IOException
}
/// <summary>
/// A release already holds the posted release id or one of its nests; nothing was recorded.
/// When <see cref="Current"/> has the posted id, an earlier post whose reply was lost was stored.
/// A release already holds the posted release id or one of its created nests; nothing was
/// recorded. When <see cref="Current"/> has the posted id, an earlier post whose reply was
/// lost was stored.
/// </summary>
public sealed class SetReleasePostConflictException : IOException
{
public SetReleasePostConflictException(SetReleaseRecord current)
: base($"Release {current.Id} of set '{current.SetName}' already records this release or one of its nests.")
: base($"Release {current.Id} already records this release or one of its nests.")
{
Current = current;
}
@@ -453,3 +506,17 @@ public sealed class SetReleasePostConflictException : IOException
/// <summary>The release that holds the id or nest.</summary>
public SetReleaseRecord Current { get; }
}
/// <summary>The server refused to undo a release; nothing was changed.</summary>
public sealed class SetReleaseUndoConflictException : IOException
{
public SetReleaseUndoConflictException(SetReleaseUndoConflict conflict)
: base(conflict.Reason == SetReleaseUndoRefusal.AlreadyUndone
? "The release was already undone."
: $"{conflict.ChangedNests.Count} of the release's nests changed since it was made.")
{
Conflict = conflict;
}
public SetReleaseUndoConflict Conflict { get; }
}
+57 -1
View File
@@ -2,11 +2,42 @@ using OpenNest;
namespace OpenNest.Data;
/// <summary>One nest of a release and the material group it was made for.</summary>
/// <summary>How a release reached one of its nests.</summary>
public enum SetReleaseNestKind
{
/// <summary>The release uploaded the nest as new.</summary>
Created,
/// <summary>The release added its parts to an existing draft nest and renested it.</summary>
Merged,
}
/// <summary>What undoing the release did to one of its nests.</summary>
public enum SetReleaseNestUndo
{
/// <summary>A created nest was deleted (it can still be recovered).</summary>
Deleted,
/// <summary>A merged nest was restored to <see cref="SetReleaseNest.RevisionBefore"/>.</summary>
Restored,
/// <summary>The operator left the nest as it was, because it had changed since the release.</summary>
Skipped,
}
/// <summary>One nest of a release, the material group it was made for, and what the release put in it.</summary>
public sealed class SetReleaseNest
{
public Guid NestId { get; set; }
public SetReleaseNestKind Kind { get; set; } = SetReleaseNestKind.Created;
/// <summary>The draft's revision the release merged into; null for a created nest.</summary>
public int? RevisionBefore { get; set; }
/// <summary>The nest revision the release wrote. Undo acts only while the nest is still at it.</summary>
public int RevisionAfter { get; set; }
public string MaterialName { get; set; } = "";
public string MaterialGrade { get; set; } = "";
@@ -18,9 +49,34 @@ public sealed class SetReleaseNest
public CutType CutType { get; set; } = CutType.Laser;
/// <summary>
/// The drawing quantities this release added to the nest, per set. Empty for releases
/// recorded before items were kept (schema v8).
/// </summary>
public IReadOnlyList<SetReleaseNestItem> Items { get; set; } = Array.Empty<SetReleaseNestItem>();
/// <summary>What undoing the release did to the nest; null while the release stands. Server-computed.</summary>
public SetReleaseNestUndo? Undo { get; set; }
/// <summary>The nest revision the undo wrote; null when it wrote none. Server-computed.</summary>
public int? UndoRevision { get; set; }
/// <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; }
}
/// <summary>A drawing quantity one set of a release added to one nest.</summary>
public sealed class SetReleaseNestItem
{
public Guid SetId { get; set; }
public Guid DrawingId { get; set; }
/// <summary>The library revision of the drawing that went into the nest.</summary>
public int DrawingRevision { get; set; }
public int Quantity { get; set; }
}
+2 -2
View File
@@ -1,11 +1,11 @@
namespace OpenNest.Data;
/// <summary>One bounded page of set releases, newest first, each with its nests.</summary>
/// <summary>One bounded page of set releases, newest first, each with its sets and 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>
/// <summary>Number of matching releases across all pages.</summary>
public int Total { get; init; }
public int Offset { get; init; }
+2 -2
View File
@@ -122,9 +122,9 @@ public sealed class SetReleasePlan
public static SetReleasePlan Build(DrawingSetRecord set, int multiplier, bool includeArchived)
{
ArgumentNullException.ThrowIfNull(set);
if (multiplier < 1 || multiplier > SetReleaseRules.MaxMultiplier)
if (multiplier < 1 || multiplier > SetReleaseRules.MaxQuantity)
throw new ArgumentOutOfRangeException(nameof(multiplier), multiplier,
$"The multiplier must be between 1 and {SetReleaseRules.MaxMultiplier}.");
$"The multiplier must be between 1 and {SetReleaseRules.MaxQuantity}.");
if (set.Id == Guid.Empty || set.Revision < 1)
throw new ArgumentException("Only a set read from the server can be released.", nameof(set));
var members = set.Members ?? throw new ArgumentException("The set has no member list.", nameof(set));
+31 -15
View File
@@ -1,34 +1,50 @@
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.
/// One release of one or more drawing sets: the nests it made or merged into, one per
/// material group, with every member quantity multiplied by its set line's quantity.
/// Releases are append-only; undoing one restores or deletes its nests and marks it
/// undone, it never removes the record.
/// </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;
/// <summary>The sets released and how many times each, in the order posted.</summary>
public IReadOnlyList<SetReleaseLine> Sets { get; set; } = Array.Empty<SetReleaseLine>();
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>
/// <summary>Who released the sets, as reported by the client.</summary>
public string ReleasedBy { get; set; } = "";
/// <summary>The nests created by the release, in the order posted.</summary>
/// <summary>When the release was undone; null while it stands. Server-computed.</summary>
public DateTime? UndoneAt { get; set; }
/// <summary>Who undid the release, as reported by the client. Server-computed.</summary>
public string UndoneBy { get; set; } = "";
/// <summary>The nests the release created or merged into, in the order posted.</summary>
public IReadOnlyList<SetReleaseNest> Nests { get; set; } = Array.Empty<SetReleaseNest>();
public bool IsUndone => UndoneAt.HasValue;
}
/// <summary>One set of a release: the exact revision released and how many kits of it.</summary>
public sealed class SetReleaseLine
{
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>How many kits: every member quantity is multiplied by this.</summary>
public int Quantity { get; set; } = 1;
/// <summary>The set's name at <see cref="SetRevision"/>. Server-joined; ignored on post.</summary>
public string SetName { get; set; } = "";
}
+101 -11
View File
@@ -3,27 +3,32 @@ 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.
/// Bounds shared by the set release client and server. Whether the set revisions, nests
/// and nest revisions exist, and whether a created nest already belongs to a release, is
/// checked by the server.
/// </summary>
public static class SetReleaseRules
{
public const int MaxMultiplier = 1000;
/// <summary>Most kits of one set in one release.</summary>
public const int MaxQuantity = 1000;
/// <summary>One nest per material group, so never more nests than a set has members.</summary>
/// <summary>Most sets in one release.</summary>
public const int MaxSetLines = 50;
/// <summary>One nest per material group.</summary>
public const int MaxNests = DrawingSetRules.MaxMembers;
/// <summary>Most drawing quantities recorded against one nest: every member of every set.</summary>
public const int MaxItemsPerNest = DrawingSetRules.MaxMembers * MaxSetLines;
/// <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);
?? DrawingLibraryRules.TextError("notes", release.Notes, DrawingLibraryRules.MaxNotesLength)
?? SetsError(release.Sets);
if (error is not null)
return error;
if (release.Nests is null || release.Nests.Count == 0)
@@ -31,6 +36,7 @@ public static class SetReleaseRules
if (release.Nests.Count > MaxNests)
return $"A release may have at most {MaxNests} nests.";
var sets = release.Sets.Select(s => s.SetId).ToHashSet();
var seen = new HashSet<Guid>();
for (var i = 0; i < release.Nests.Count; i++)
{
@@ -41,17 +47,101 @@ public static class SetReleaseRules
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)
error = RevisionsError(i, nest)
?? 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);
?? (!Enum.IsDefined(nest.CutType) ? $"nests[{i}].cutType must be Laser, Waterjet or Plasma." : null)
?? ItemsError(i, nest.Items, sets);
if (error is not null)
return error;
}
return null;
}
/// <summary>The first rule <paramref name="request"/> breaks, or null when it may be sent.</summary>
public static string? GetUndoError(SetReleaseUndoRequest? request)
{
if (request is null)
return "Missing undo request.";
if (request.SkipNests is null)
return "skipNests must be a list.";
if (request.SkipNests.Count > MaxNests)
return $"skipNests may list at most {MaxNests} nests.";
if (request.SkipNests.Contains(Guid.Empty))
return "skipNests may not contain an empty id.";
return DrawingLibraryRules.TextError("undoneBy", request.UndoneBy, DrawingLibraryRules.MaxTextLength);
}
private static string? SetsError(IReadOnlyList<SetReleaseLine>? lines)
{
if (lines is null || lines.Count == 0)
return "sets must list at least one set.";
if (lines.Count > MaxSetLines)
return $"A release may have at most {MaxSetLines} sets.";
var seen = new HashSet<Guid>();
for (var i = 0; i < lines.Count; i++)
{
var line = lines[i];
if (line is null)
return $"sets[{i}] is required.";
if (line.SetId == Guid.Empty)
return $"sets[{i}].setId is required.";
if (!seen.Add(line.SetId))
return $"sets[{i}] repeats set {line.SetId}.";
if (line.SetRevision < 1)
return $"sets[{i}].setRevision must be a positive revision.";
if (line.Quantity < 1 || line.Quantity > MaxQuantity)
return $"sets[{i}].quantity must be between 1 and {MaxQuantity}.";
}
return null;
}
private static string? RevisionsError(int i, SetReleaseNest nest)
{
if (!Enum.IsDefined(nest.Kind))
return $"nests[{i}].kind must be created or merged.";
if (nest.RevisionAfter < 1)
return $"nests[{i}].revisionAfter must be a positive revision.";
if (nest.Kind == SetReleaseNestKind.Created)
return nest.RevisionBefore is null ? null : $"nests[{i}].revisionBefore must be empty for a created nest.";
if (nest.RevisionBefore is not { } before || before < 1)
return $"nests[{i}].revisionBefore is required for a merged nest.";
return nest.RevisionAfter > before ? null : $"nests[{i}].revisionAfter must be later than revisionBefore.";
}
private static string? ItemsError(int i, IReadOnlyList<SetReleaseNestItem>? items, HashSet<Guid> sets)
{
if (items is null)
return $"nests[{i}].items is required.";
if (items.Count > MaxItemsPerNest)
return $"nests[{i}] may record at most {MaxItemsPerNest} items.";
var seen = new HashSet<(Guid, Guid)>();
for (var j = 0; j < items.Count; j++)
{
var item = items[j];
var name = $"nests[{i}].items[{j}]";
if (item is null)
return $"{name} is required.";
if (!sets.Contains(item.SetId))
return $"{name}.setId must be one of the release's sets.";
if (item.DrawingId == Guid.Empty)
return $"{name}.drawingId is required.";
if (!seen.Add((item.SetId, item.DrawingId)))
return $"{name} repeats drawing {item.DrawingId} of set {item.SetId}.";
if (item.DrawingRevision < 1)
return $"{name}.drawingRevision must be a positive revision.";
if (item.Quantity < 1)
return $"{name}.quantity must be at least 1.";
}
return null;
}
}
+14 -4
View File
@@ -236,14 +236,24 @@ public sealed class SetReleaseSession
_pendingRelease = new SetReleaseRecord
{
Id = Guid.NewGuid(),
SetId = plan.Set.Id,
SetRevision = plan.Set.Revision,
Multiplier = plan.Multiplier,
Sets = new[]
{
new SetReleaseLine { SetId = plan.Set.Id, SetRevision = plan.Set.Revision, Quantity = plan.Multiplier },
},
Notes = notes ?? "",
ReleasedBy = releasedBy ?? "",
Nests = results.Select(r => new SetReleaseNest
{
NestId = r.Record!.Id,
Kind = SetReleaseNestKind.Created,
RevisionAfter = r.Record.Revision,
Items = r.Group.Items.Select(i => new SetReleaseNestItem
{
SetId = plan.Set.Id,
DrawingId = i.Drawing.Id,
DrawingRevision = i.Drawing.Revision,
Quantity = i.Quantity,
}).ToList(),
MaterialName = r.Group.MaterialName,
MaterialGrade = r.Group.MaterialGrade,
Thickness = r.Group.Thickness,
@@ -361,7 +371,7 @@ public sealed class SetReleaseSession
SetReleaseRecord stored;
try
{
stored = await _sets.PostReleaseAsync(release.SetId, release, cancellationToken);
stored = await _sets.PostReleaseAsync(release, cancellationToken);
}
catch (SetReleasePostConflictException conflict) when (conflict.Current.Id == release.Id)
{
+26 -6
View File
@@ -6,23 +6,43 @@ namespace OpenNest.Data;
/// <summary>Operator-facing wording for set releases, shared by the release views and Nest Info.</summary>
public static class SetReleaseText
{
/// <summary>Where a nest came from, such as "Released from set 'Kit A' revision 3 ×2 by J. Smith".</summary>
public static string Origin(SetReleaseRecord release)
/// <summary>
/// Where a nest came from, such as "Released from set 'Kit A' revision 3 ×2 by J. Smith",
/// or "Merged from sets 'Kit A' revision 3 ×2 and 'Kit B' revision 1 ×1 ..." for a release
/// that added to <paramref name="nestId"/> rather than creating it.
/// </summary>
public static string Origin(SetReleaseRecord release, Guid nestId = default)
{
ArgumentNullException.ThrowIfNull(release);
var text = $"Released from set '{release.SetName}' revision {release.SetRevision} ×{release.Multiplier}";
var merged = release.Nests?.FirstOrDefault(n => n.NestId == nestId)?.Kind == SetReleaseNestKind.Merged;
var sets = release.Sets ?? Array.Empty<SetReleaseLine>();
var text = $"{(merged ? "Merged" : "Released")} from {(sets.Count == 1 ? "set" : "sets")} {Sets(sets)}";
if (release.ReleasedAt != default)
text += $" on {release.ReleasedAt.ToLocalTime().ToString("d", CultureInfo.CurrentCulture)}";
return string.IsNullOrWhiteSpace(release.ReleasedBy) ? text : $"{text} by {release.ReleasedBy}";
if (!string.IsNullOrWhiteSpace(release.ReleasedBy))
text += $" by {release.ReleasedBy}";
return release.IsUndone ? $"{text} (undone)" : text;
}
/// <summary>The sets of a release, such as "'Kit A' revision 3 ×2 and 'Kit B' revision 1 ×1".</summary>
public static string Sets(IReadOnlyList<SetReleaseLine> sets)
{
ArgumentNullException.ThrowIfNull(sets);
var names = sets.Select(s => $"'{s.SetName}' revision {s.SetRevision} ×{s.Quantity}").ToList();
return names.Count <= 1
? string.Concat(names)
: string.Join(", ", names.Take(names.Count - 1)) + " and " + names[^1];
}
/// <summary>
/// How far the release's nests have got, such as "2 of 3 cut" or "1 of 2 cut, 1 deleted".
/// Deleted nests are left out of the count.
/// How far the release's nests have got, such as "2 of 3 cut" or "1 of 2 cut, 1 deleted",
/// or "Undone". Deleted nests are left out of the count.
/// </summary>
public static string Progress(SetReleaseRecord release)
{
ArgumentNullException.ThrowIfNull(release);
if (release.IsUndone)
return "Undone";
var nests = release.Nests ?? Array.Empty<SetReleaseNest>();
var deleted = nests.Count(IsDeleted);
var active = nests.Count - deleted;
+34
View File
@@ -0,0 +1,34 @@
namespace OpenNest.Data;
/// <summary>An undo request: who is undoing the release, and which changed nests to leave as they are.</summary>
public sealed class SetReleaseUndoRequest
{
public string UndoneBy { get; set; } = "";
/// <summary>
/// Nests of the release to leave alone. Every other nest must still be at the revision
/// the release wrote, or nothing is undone.
/// </summary>
public IReadOnlyList<Guid> SkipNests { get; set; } = Array.Empty<Guid>();
}
public enum SetReleaseUndoRefusal
{
/// <summary>The release was undone before.</summary>
AlreadyUndone,
/// <summary>Nests not skipped have changed since the release; <see cref="SetReleaseUndoConflict.ChangedNests"/> lists them.</summary>
NestsChanged,
}
/// <summary>Why an undo was refused. Nothing was changed.</summary>
public sealed class SetReleaseUndoConflict
{
public SetReleaseUndoRefusal Reason { get; set; }
/// <summary>The release as it is now.</summary>
public SetReleaseRecord Release { get; set; } = new();
/// <summary>The current metadata of each nest that moved on since the release, deleted or not.</summary>
public IReadOnlyList<NestRecord> ChangedNests { get; set; } = Array.Empty<NestRecord>();
}
@@ -4,6 +4,7 @@ using System.Text;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
using static OpenNest.Server.Tests.DrawingLibraryFixtures;
using static OpenNest.Server.Tests.DrawingSetFixtures;
namespace OpenNest.Server.Tests;
@@ -21,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -42,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -79,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
Execute(factory.DatabasePath, "PRAGMA user_version=9");
Execute(factory.DatabasePath, "PRAGMA user_version=10");
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -235,8 +236,8 @@ public sealed class DrawingLibraryDatabaseTests
database.SetDeleted(a.Id, false, 3);
}
Execute(path, """
DROP TABLE set_release_nests; DROP TABLE set_releases;
Execute(path, $"""
{DropReleaseTables}
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;
""");
+30 -12
View File
@@ -74,14 +74,14 @@ public sealed class DrawingSetClientTests
Assert.Null(await store.GetRevisionAsync(set.Id, 2));
Assert.Null(await store.GetRevisionsAsync(Guid.NewGuid()));
Assert.Null(await store.GetSetsContainingAsync(Guid.NewGuid()));
Assert.Null(await store.GetReleasesAsync(Guid.NewGuid()));
Assert.Null(await store.GetReleaseForNestAsync(Guid.NewGuid()));
Assert.Empty((await store.QueryReleasesAsync(setId: Guid.NewGuid())).Items);
Assert.Null(await store.GetReleaseAsync(Guid.NewGuid()));
var unknown = Set();
await Assert.ThrowsAsync<IOException>(() => store.UpdateAsync(unknown.Id, unknown, 1));
}
[Fact]
public async Task Releases_PostReadAndRefuseANestTwice()
public async Task Releases_PostReadUndoAndRefuseACreatedNestTwice()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
@@ -89,21 +89,39 @@ public sealed class DrawingSetClientTests
var set = await store.CreateAsync(Set());
var steel = await AddNest(client, "Synthetic steel nest");
var aluminum = await AddNest(client, "Synthetic aluminum nest");
var release = Release(1, steel, aluminum);
var release = Release(set.Id, 1, steel, aluminum);
var stored = await store.PostReleaseAsync(set.Id, release);
var repost = await Assert.ThrowsAsync<SetReleasePostConflictException>(() => store.PostReleaseAsync(set.Id, release));
var reuse = await Assert.ThrowsAsync<SetReleasePostConflictException>(() => store.PostReleaseAsync(set.Id, Release(1, steel)));
var stored = await store.PostReleaseAsync(release);
var repost = await Assert.ThrowsAsync<SetReleasePostConflictException>(() => store.PostReleaseAsync(release));
var reuse = await Assert.ThrowsAsync<SetReleasePostConflictException>(() => store.PostReleaseAsync(Release(set.Id, 1, steel)));
Assert.Equal((release.Id, set.Id, "Synthetic kit", 2), (stored.Id, stored.SetId, stored.SetName, stored.Multiplier));
Assert.Equal((release.Id, set.Id, "Synthetic kit", 2), (stored.Id, stored.Sets[0].SetId, stored.Sets[0].SetName, stored.Sets[0].Quantity));
Assert.Equal(new[] { steel, aluminum }, stored.Nests.Select(n => n.NestId));
Assert.Equal(release.Id, repost.Current.Id);
Assert.Equal(release.Id, reuse.Current.Id);
var page = await store.GetReleasesAsync(set.Id);
var listed = Assert.Single(page!.Items);
var page = await store.QueryReleasesAsync(setId: set.Id);
var listed = Assert.Single(page.Items);
Assert.Equal(new[] { "Synthetic steel nest", "Synthetic aluminum nest" }, listed.Nests.Select(n => n.Nest!.Name));
Assert.Equal(release.Id, (await store.GetReleaseForNestAsync(aluminum))!.Id);
await Assert.ThrowsAsync<IOException>(() => store.PostReleaseAsync(Guid.NewGuid(), Release(1, Guid.NewGuid())));
Assert.Equal(release.Id, (await store.QueryReleasesAsync(nestId: aluminum)).Items.Single().Id);
Assert.Equal(release.Id, (await store.GetReleaseAsync(release.Id))!.Id);
await Assert.ThrowsAsync<IOException>(() => store.PostReleaseAsync(Release(Guid.NewGuid(), 1, Guid.NewGuid())));
// Someone saves the steel nest after the release: undo refuses until it is skipped.
using var nests = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var record = (await nests.GetMetadataAsync(steel))!;
record.Comments = "Edited after the release";
await nests.UpdateMetadataAsync(steel, record, 1);
var refused = await Assert.ThrowsAsync<SetReleaseUndoConflictException>(() =>
store.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" }));
Assert.Equal((SetReleaseUndoRefusal.NestsChanged, steel), (refused.Conflict.Reason, refused.Conflict.ChangedNests.Single().Id));
var undone = await store.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer", SkipNests = new[] { steel } });
Assert.Equal(new SetReleaseNestUndo?[] { SetReleaseNestUndo.Skipped, SetReleaseNestUndo.Deleted }, undone.Nests.Select(n => n.Undo));
Assert.Null(await nests.GetMetadataAsync(aluminum));
var again = await Assert.ThrowsAsync<SetReleaseUndoConflictException>(() => store.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest()));
Assert.Equal(SetReleaseUndoRefusal.AlreadyUndone, again.Conflict.Reason);
await Assert.ThrowsAsync<IOException>(() => store.UndoReleaseAsync(Guid.NewGuid(), new SetReleaseUndoRequest()));
}
}
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
Assert.Equal(before, Digest(factory.DatabasePath));
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
Assert.Equal(before, Digest(factory.DatabasePath));
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
@@ -314,8 +314,8 @@ public sealed class DrawingSetDatabaseTests
database.UpdateDrawing(drawing, edit, null, 1);
}
Execute(path, """
DROP TABLE set_release_nests; DROP TABLE set_releases;
Execute(path, $"""
{DropReleaseTables}
DROP TABLE drawing_set_members; DROP TABLE drawing_sets; DROP TABLE drawing_set_revisions; PRAGMA user_version=6;
""");
return drawing;
+55 -4
View File
@@ -34,6 +34,36 @@ internal static class DrawingSetFixtures
return (await Upload(client, record, Package(name))).Id;
}
/// <summary>Drops the v9 release tables, for fixtures that rebuild a schema older than v7.</summary>
public const string DropReleaseTables = """
DROP TABLE release_nest_items; DROP TABLE release_nests; DROP TABLE release_sets; DROP TABLE releases;
""";
/// <summary>Puts back the v7/v8 release tables in place of the v9 ones, for fixtures that rebuild v7 or v8.</summary>
public const string ReleaseTablesBackToV8 = DropReleaseTables + """
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);
""";
public static StringContent Json(string json) => new(json, Encoding.UTF8, "application/json");
public static StringContent Json(DrawingSetRecord record) => Json(JsonSerializer.Serialize(record, Options));
@@ -68,17 +98,22 @@ internal static class DrawingSetFixtures
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()
/// <summary>
/// Two kits of <paramref name="setRevision"/> of <paramref name="setId"/>, creating one nest
/// per id (each at revision 1, as <see cref="AddNest(NestDatabase, string)"/> stores it), each
/// in its own material.
/// </summary>
public static SetReleaseRecord Release(Guid setId, int setRevision, params Guid[] nests) => new()
{
Id = Guid.NewGuid(),
SetRevision = setRevision,
Multiplier = 2,
Sets = new[] { new SetReleaseLine { SetId = setId, SetRevision = setRevision, Quantity = 2 } },
Notes = "Synthetic release",
ReleasedBy = "Synthetic operator",
Nests = nests.Select((nest, i) => new SetReleaseNest
{
NestId = nest,
Kind = SetReleaseNestKind.Created,
RevisionAfter = 1,
MaterialName = "Synthetic Steel " + i,
MaterialGrade = "Grade A",
Thickness = 0.25,
@@ -86,4 +121,20 @@ internal static class DrawingSetFixtures
CutType = CutType.Laser,
}).ToList(),
};
/// <summary>Turns nest <paramref name="index"/> of <paramref name="release"/> into a merge from one revision to another.</summary>
public static SetReleaseRecord Merged(this SetReleaseRecord release, int index, int before, int after)
{
release.Nests[index].Kind = SetReleaseNestKind.Merged;
release.Nests[index].RevisionBefore = before;
release.Nests[index].RevisionAfter = after;
return release;
}
/// <summary>Saves the nest again with new bytes, as an operator or a merge would, and returns the new revision.</summary>
public static int Resave(NestDatabase database, Guid nest, byte last = 9)
{
var current = database.Get(nest)!;
return database.Update(nest, current, new byte[] { 1, 2, last }, current.Revision)!.Revision;
}
}
@@ -61,7 +61,8 @@ public sealed class NestMaterialGradeTests
var id = Guid.NewGuid();
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
database.Insert(id, Record("Legacy", "Stainless", "304"), [1]);
Execute(factory.DatabasePath, """
Execute(factory.DatabasePath, $"""
{DrawingSetFixtures.ReleaseTablesBackToV8}
ALTER TABLE nests DROP COLUMN materialGrade;
ALTER TABLE nest_revisions DROP COLUMN materialGrade;
PRAGMA user_version=7;
@@ -73,7 +74,7 @@ public sealed class NestMaterialGradeTests
Assert.Equal("Stainless", record.Material);
Assert.Equal("", record.MaterialGrade);
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade);
Assert.Equal(8L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
@@ -106,13 +106,22 @@ public sealed class SetReleaseEndToEndTests
var steel = result.Groups.Single(g => g.Group.MaterialName == "Synthetic Steel");
Assert.Equal(new[] { "SYN-A", "SYN-B" }, steel.Group.Items.Select(i => i.Drawing.Name));
var releases = await sets.GetReleasesAsync(created.Id);
var release = Assert.Single(releases!.Items);
var releases = await sets.QueryReleasesAsync(setId: created.Id);
var release = Assert.Single(releases.Items);
var line = Assert.Single(release.Sets);
Assert.Equal((result.Release!.Id, 1, 2, "Synthetic release notes"),
(release.Id, release.SetRevision, release.Multiplier, release.Notes));
(release.Id, line.SetRevision, line.Quantity, release.Notes));
Assert.Equal(result.Groups.Select(g => g.Record!.Id), release.Nests.Select(n => n.NestId));
Assert.All(release.Nests, n => Assert.Equal(NestStatus.Draft, n.Nest!.Status));
Assert.All(release.Nests, n => Assert.Equal((NestStatus.Draft, SetReleaseNestKind.Created, 1), (n.Nest!.Status, n.Kind, n.RevisionAfter)));
Assert.Equal(CutType.Waterjet, release.Nests.Single(n => n.MaterialName == "Synthetic Aluminum").CutType);
Assert.Equal(release.Id, (await sets.GetReleaseForNestAsync(steel.Record!.Id))!.Id);
Assert.Equal(new[] { (a.Id, 6), (b.Id, 2) },
release.Nests.Single(n => n.NestId == steel.Record!.Id).Items.Select(i => (i.DrawingId, i.Quantity)));
Assert.Equal(release.Id, (await sets.QueryReleasesAsync(nestId: steel.Record!.Id)).Items.Single().Id);
// Undo deletes every nest the release created.
var undone = await sets.UndoReleaseAsync(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic operator" });
Assert.All(undone.Nests, n => Assert.Equal(SetReleaseNestUndo.Deleted, n.Undo));
foreach (var group in result.Groups)
Assert.Null(await nests.GetMetadataAsync(group.Record!.Id));
}
}
+331 -90
View File
@@ -13,32 +13,47 @@ namespace OpenNest.Server.Tests;
public sealed class SetReleaseTests
{
[Fact]
public void Release_OfAStaleRevision_IsStoredAndJoinsItsSetNameAndNests()
public void Release_OfSeveralSetsAtStaleRevisions_IsStoredWithNamesNestsAndItems()
{
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);
var kit = Set("Kit as released", members: (drawing, 1));
var other = Set("Other kit", members: (drawing, 2));
database.InsertDrawingSet(kit.Id, kit);
database.InsertDrawingSet(other.Id, other);
database.UpdateDrawingSet(kit.Id, Set("Kit renamed later", kit.Id, (drawing, 3)), 1);
var created = AddNest(database, "Created nest");
var draft = AddNest(database, "Draft nest");
var merged = Resave(database, draft);
var release = Release(kit.Id, 1, created, draft).Merged(1, before: 1, after: merged);
release.Sets = release.Sets.Append(new SetReleaseLine { SetId = other.Id, SetRevision = 1, Quantity = 3 }).ToList();
release.Nests[1].Thickness = 0.123456;
release.Nests[1].Units = Units.Millimeters;
release.Nests[1].CutType = CutType.Waterjet;
release.Nests[1].Items = new[]
{
new SetReleaseNestItem { SetId = kit.Id, DrawingId = drawing, DrawingRevision = 1, Quantity = 2 },
new SetReleaseNestItem { SetId = other.Id, DrawingId = drawing, DrawingRevision = 1, Quantity = 6 },
};
var stored = database.InsertSetRelease(set.Id, release)!;
var stored = database.InsertRelease(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.Equal(release.Id, stored.Id);
Assert.Equal(new[] { (kit.Id, 1, 2, "Kit as released"), (other.Id, 1, 3, "Other kit") },
stored.Sets.Select(s => (s.SetId, s.SetRevision, s.Quantity, s.SetName)));
Assert.Equal(("Synthetic release", "Synthetic operator", false), (stored.Notes, stored.ReleasedBy, stored.IsUndone));
Assert.NotEqual(default, stored.ReleasedAt);
Assert.Equal(new[] { first, second }, stored.Nests.Select(n => n.NestId));
Assert.Equal(new[] { (created, SetReleaseNestKind.Created, (int?)null, 1), (draft, SetReleaseNestKind.Merged, 1, 2) },
stored.Nests.Select(n => (n.NestId, n.Kind, n.RevisionBefore, n.RevisionAfter)));
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)));
Assert.Equal(new[] { (kit.Id, 2), (other.Id, 6) }, stored.Nests[1].Items.Select(i => (i.SetId, i.Quantity)));
Assert.Empty(stored.Nests[0].Items);
Assert.Equal(("Created nest", NestStatus.Draft, false), (stored.Nests[0].Nest!.Name, stored.Nests[0].Nest!.Status, stored.Nests[0].Nest!.Deleted));
Assert.Null(stored.Nests[0].Undo);
Assert.Equal(release.Id, database.GetRelease(release.Id)!.Id);
Assert.Null(database.GetRelease(Guid.NewGuid()));
}
[Fact]
@@ -50,21 +65,21 @@ public sealed class SetReleaseTests
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));
database.InsertRelease(Release(set.Id, 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;
var nests = database.QueryReleases(set.Id, null, 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);
Assert.True(database.QueryReleases(null, removed, 0, 100).Items.Single().Nests[1].Nest!.Deleted);
}
[Fact]
public void Releases_PageNewestFirstPerSetAndAcceptAnArchivedSet()
public void Query_FiltersBySetAndNest_NewestFirst_AndAcceptsAnArchivedSet()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -75,23 +90,29 @@ public sealed class SetReleaseTests
var ids = new List<Guid>();
for (var i = 0; i < 3; i++)
{
var release = Release(1, AddNest(database));
var release = Release(set.Id, 1, AddNest(database));
ids.Add(release.Id);
database.InsertSetRelease(set.Id, release);
database.InsertRelease(release);
Thread.Sleep(5);
}
database.InsertSetRelease(other.Id, Release(1, AddNest(database)));
var shared = AddNest(database);
var onOther = Release(other.Id, 1, shared);
database.InsertRelease(onOther);
Thread.Sleep(5);
database.SetDrawingSetArchived(set.Id, true, 1);
var afterArchive = Release(1, AddNest(database));
Assert.NotNull(database.InsertSetRelease(set.Id, afterArchive));
ids.Add(afterArchive.Id);
var merge = Release(set.Id, 1, shared).Merged(0, before: 1, after: Resave(database, shared));
database.InsertRelease(merge);
ids.Add(merge.Id);
var page = database.SetReleases(set.Id, 1, 2)!;
var page = database.QueryReleases(set.Id, null, 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));
Assert.Equal(new[] { onOther.Id }, database.QueryReleases(other.Id, null, 0, 100).Items.Select(r => r.Id));
Assert.Equal(new[] { merge.Id, onOther.Id }, database.QueryReleases(null, shared, 0, 100).Items.Select(r => r.Id));
Assert.Equal(new[] { merge.Id }, database.QueryReleases(set.Id, shared, 0, 100).Items.Select(r => r.Id));
Assert.Equal(5, database.QueryReleases(null, null, 0, 100).Total);
Assert.Empty(database.QueryReleases(Guid.NewGuid(), null, 0, 100).Items);
Assert.Throws<ArgumentOutOfRangeException>(() => database.QueryReleases(set.Id, null, 0, 201));
}
[Fact]
@@ -105,38 +126,194 @@ public sealed class SetReleaseTests
var taken = AddNest(database);
var deleted = AddNest(database);
database.SetDeleted(deleted, true, 1);
var owner = Release(1, taken);
database.InsertSetRelease(set.Id, owner);
var owner = Release(set.Id, 1, taken);
database.InsertRelease(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);
Assert.Contains("does not exist", Assert.Throws<ArgumentException>(() => database.InsertRelease(Release(Guid.NewGuid(), 1, free))).Message);
Assert.Contains("no revision 2", Assert.Throws<ArgumentException>(() => database.InsertRelease(Release(set.Id, 2, free))).Message);
Assert.Contains("does not exist", Assert.Throws<ArgumentException>(() => database.InsertRelease(Release(set.Id, 1, free, Guid.NewGuid()))).Message);
Assert.Contains("is deleted", Assert.Throws<ArgumentException>(() => database.InsertRelease(Release(set.Id, 1, free, deleted))).Message);
Assert.Contains("no revision 3", Assert.Throws<ArgumentException>(() => database.InsertRelease(Release(set.Id, 1, free).Merged(0, 1, 3))).Message);
var after = Release(set.Id, 1, free);
after.Nests[0].RevisionAfter = 2;
Assert.Contains("no revision 2", Assert.Throws<ArgumentException>(() => database.InsertRelease(after)).Message);
Assert.Throws<ArgumentException>(() => database.InsertRelease(Release(set.Id, 1, free, free)));
Assert.Throws<ArgumentException>(() => database.InsertRelease(Release(set.Id, 1)));
var twice = Release(set.Id, 1, free);
twice.Sets = twice.Sets.Concat(twice.Sets).ToList();
Assert.Throws<ArgumentException>(() => database.InsertRelease(twice));
var noId = Release(set.Id, 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));
Assert.Throws<ArgumentException>(() => database.InsertRelease(noId));
var tooMany = Release(set.Id, 1, free);
tooMany.Sets[0].Quantity = SetReleaseRules.MaxQuantity + 1;
Assert.Throws<ArgumentException>(() => database.InsertRelease(tooMany));
var strangerItem = Release(set.Id, 1, free);
strangerItem.Nests[0].Items = new[] { new SetReleaseNestItem { SetId = Guid.NewGuid(), DrawingId = Guid.NewGuid(), DrawingRevision = 1, Quantity = 1 } };
Assert.Throws<ArgumentException>(() => database.InsertRelease(strangerItem));
var claimed = Assert.Throws<SetReleaseConflictException>(() => database.InsertSetRelease(set.Id, Release(1, free, taken)));
var claimed = Assert.Throws<SetReleaseConflictException>(() => database.InsertRelease(Release(set.Id, 1, free, taken)));
Assert.Equal(owner.Id, claimed.Current.Id);
var reused = Release(1, free);
var reused = Release(set.Id, 1, free);
reused.Id = owner.Id;
Assert.Equal(owner.Id, Assert.Throws<SetReleaseConflictException>(() => database.InsertSetRelease(set.Id, reused)).Current.Id);
Assert.Equal(owner.Id, Assert.Throws<SetReleaseConflictException>(() => database.InsertRelease(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));
Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM releases"));
Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_sets"));
Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM release_nests"));
Assert.Empty(database.QueryReleases(null, free, 0, 100).Items);
}
[Fact]
public void MergedNest_MayBeInSeveralReleases_ButACreatedNestOnlyInItsOwn()
{
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);
database.InsertRelease(Release(set.Id, 1, nest));
database.InsertRelease(Release(set.Id, 1, nest).Merged(0, 1, Resave(database, nest)));
database.InsertRelease(Release(set.Id, 1, nest).Merged(0, 2, Resave(database, nest, 8)));
Assert.Equal(3, database.QueryReleases(null, nest, 0, 100).Total);
}
[Fact]
public void Undo_DeletesCreatedNests_AndRestoresMergedOnes_InOneStep()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var set = Set();
database.InsertDrawingSet(set.Id, set);
var created = AddNest(database, "Created nest");
var draft = AddNest(database, "Draft nest");
var release = Release(set.Id, 1, created, draft).Merged(1, 1, Resave(database, draft));
database.InsertRelease(release);
var undone = database.UndoRelease(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" })!;
Assert.True(undone.IsUndone);
Assert.Equal("Synthetic undoer", undone.UndoneBy);
Assert.Equal(new[] { (SetReleaseNestUndo.Deleted, (int?)2), (SetReleaseNestUndo.Restored, 3) },
undone.Nests.Select(n => (n.Undo!.Value, n.UndoRevision)));
Assert.Null(database.Get(created));
Assert.Equal(2, database.Get(created, deleted: true)!.Revision);
var restored = database.Get(draft)!;
Assert.Equal((3, (int?)1), (restored.Revision, restored.RestoredFromRevision));
Assert.Equal(new byte[] { 1, 2, 3 }, database.GetFile(draft));
Assert.Equal("Undone", SetReleaseText.Progress(database.GetRelease(release.Id)!));
Assert.Null(database.UndoRelease(Guid.NewGuid(), new SetReleaseUndoRequest()));
}
[Fact]
public void Undo_WhenANestChanged_ChangesNothing_UntilThatNestIsSkipped()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var set = Set();
database.InsertDrawingSet(set.Id, set);
var edited = AddNest(database, "Edited later");
var untouched = AddNest(database, "Untouched");
var removed = AddNest(database, "Deleted later");
var release = Release(set.Id, 1, edited, untouched, removed);
database.InsertRelease(release);
Resave(database, edited);
database.SetDeleted(removed, true, 1);
var refused = Assert.Throws<ReleaseUndoRefusedException>(() =>
database.UndoRelease(release.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic undoer" })).Conflict;
Assert.Equal(SetReleaseUndoRefusal.NestsChanged, refused.Reason);
Assert.Equal(new[] { (edited, 2, false), (removed, 2, true) }, refused.ChangedNests.Select(n => (n.Id, n.Revision, n.Deleted)));
Assert.False(database.GetRelease(release.Id)!.IsUndone);
Assert.NotNull(database.Get(untouched));
var skipped = database.UndoRelease(release.Id, new SetReleaseUndoRequest { SkipNests = new[] { edited, removed } })!;
Assert.Equal(new SetReleaseNestUndo?[] { SetReleaseNestUndo.Skipped, SetReleaseNestUndo.Deleted, SetReleaseNestUndo.Skipped },
skipped.Nests.Select(n => n.Undo));
Assert.Null(skipped.Nests[0].UndoRevision);
Assert.Equal(2, database.Get(edited)!.Revision);
Assert.Null(database.Get(untouched));
var again = Assert.Throws<ReleaseUndoRefusedException>(() => database.UndoRelease(release.Id, new SetReleaseUndoRequest()));
Assert.Equal(SetReleaseUndoRefusal.AlreadyUndone, again.Conflict.Reason);
Assert.Throws<ArgumentException>(() => database.UndoRelease(Release(set.Id, 1).Id, new SetReleaseUndoRequest { SkipNests = new[] { Guid.Empty } }));
}
[Fact]
public void Undo_OfReleasesSharingANest_GoesNewestFirst()
{
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 first = Release(set.Id, 1, nest);
database.InsertRelease(first);
var second = Release(set.Id, 1, nest).Merged(0, 1, Resave(database, nest));
database.InsertRelease(second);
var stranger = Release(set.Id, 1, AddNest(database));
database.InsertRelease(stranger);
Assert.Contains("is not part", Assert.Throws<ArgumentException>(() =>
database.UndoRelease(first.Id, new SetReleaseUndoRequest { SkipNests = stranger.Nests.Select(n => n.NestId).ToList() })).Message);
Assert.Throws<ReleaseUndoRefusedException>(() => database.UndoRelease(first.Id, new SetReleaseUndoRequest()));
database.UndoRelease(second.Id, new SetReleaseUndoRequest());
// The later undo restored revision 1 as revision 3, which the first release still accepts.
var undone = database.UndoRelease(first.Id, new SetReleaseUndoRequest())!;
Assert.Equal(SetReleaseNestUndo.Deleted, undone.Nests.Single().Undo);
Assert.Equal(4, database.Get(nest, deleted: true)!.Revision);
}
[Fact]
public void Version8Releases_MigrateToOneSetLine_AndCreatedNests()
{
using var factory = new ServerFactory();
var set = Set();
Guid first, second;
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
database.InsertDrawingSet(set.Id, set);
first = AddNest(database, "First");
second = AddNest(database, "Second");
Resave(database, second);
}
var releaseId = Guid.NewGuid();
Execute(factory.DatabasePath, $"""
{ReleaseTablesBackToV8}
INSERT INTO set_releases (id, setId, setRevision, multiplier, notes, releasedAt, releasedBy)
VALUES ('{releaseId}', '{set.Id}', 1, 4, 'Legacy notes', '2026-10-20T09:00:00.0000000-04:00', 'Legacy operator');
INSERT INTO set_release_nests (releaseId, position, nestId, materialName, materialGrade, thickness, units, cutType)
VALUES ('{releaseId}', 0, '{first}', 'Synthetic Steel', 'A36', 0.25, 'Inches', 'Laser'),
('{releaseId}', 1, '{second}', 'Synthetic Aluminum', '', 0.125, 'Millimeters', 'Waterjet');
PRAGMA user_version=8;
""");
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(9L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'"));
var release = upgraded.GetRelease(releaseId)!;
Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone));
Assert.Equal((set.Id, 1, 4, "Synthetic kit"), (release.Sets.Single().SetId, release.Sets.Single().SetRevision, release.Sets.Single().Quantity, release.Sets.Single().SetName));
Assert.Equal(new[] { (first, SetReleaseNestKind.Created, (int?)null, 1), (second, SetReleaseNestKind.Created, null, 1) },
release.Nests.Select(n => (n.NestId, n.Kind, n.RevisionBefore, n.RevisionAfter)));
Assert.Equal((Units.Millimeters, CutType.Waterjet, 0.125), (release.Nests[1].Units, release.Nests[1].CutType, release.Nests[1].Thickness));
Assert.All(release.Nests, n => Assert.Empty(n.Items));
// The second nest was saved after its release, so undoing must leave it unless skipped.
var refused = Assert.Throws<ReleaseUndoRefusedException>(() => upgraded.UndoRelease(releaseId, new SetReleaseUndoRequest()));
Assert.Equal(second, refused.Conflict.ChangedNests.Single().Id);
}
// Code-level ownership gate, as for the set operations.
[Theory]
[InlineData("InsertSetRelease")]
[InlineData("SetReleases")]
[InlineData("ReleaseForNest")]
[InlineData("InsertRelease")]
[InlineData("GetRelease")]
[InlineData("QueryReleases")]
[InlineData("UndoRelease")]
public async Task Concurrency_EveryReleaseOperationWaitsForTheSameMonitor(string operation)
{
using var factory = new ServerFactory();
@@ -145,16 +322,18 @@ public sealed class SetReleaseTests
database.InsertDrawingSet(set.Id, set);
var nest = AddNest(database);
var next = AddNest(database);
database.InsertSetRelease(set.Id, Release(1, nest));
var release = Release(set.Id, 1, nest);
database.InsertRelease(release);
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;
case "InsertRelease": database.InsertRelease(Release(set.Id, 1, next)); break;
case "GetRelease": database.GetRelease(release.Id); break;
case "QueryReleases": database.QueryReleases(set.Id, nest, 0, 100); break;
case "UndoRelease": database.UndoRelease(release.Id, new SetReleaseUndoRequest()); break;
default: throw new InvalidOperationException($"Unknown test operation: {operation}");
}
})))
@@ -184,6 +363,7 @@ public sealed class SetReleaseTests
var set = Set();
database.InsertDrawingSet(set.Id, set);
var nest = AddNest(database);
var release = Release(set.Id, 1, nest);
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)!;
@@ -200,9 +380,10 @@ public sealed class SetReleaseTests
{
Action[] operations =
[
() => database.InsertSetRelease(set.Id, Release(1, nest)),
() => database.SetReleases(set.Id, 0, 100),
() => database.ReleaseForNest(nest),
() => database.InsertRelease(release),
() => database.GetRelease(release.Id),
() => database.QueryReleases(set.Id, nest, 0, 100),
() => database.UndoRelease(release.Id, new SetReleaseUndoRequest()),
];
foreach (var operation in operations)
{
@@ -219,72 +400,105 @@ public sealed class SetReleaseTests
}
[Fact]
public async Task Api_PostsAndReadsReleasesWithCamelCaseEnums()
public async Task Api_PostsQueriesAndReadsReleasesWithCamelCaseEnums()
{
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);
var release = Release(set.Id, 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)));
using var posted = await client.PostAsync("/api/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(set.Id.ToString(), json["sets"]![0]!["setId"]!.GetValue<string>());
Assert.Equal("Synthetic kit", json["sets"]![0]!["setName"]!.GetValue<string>());
Assert.Equal("millimeters", json["nests"]![1]!["units"]!.GetValue<string>());
Assert.Equal("plasma", json["nests"]![1]!["cutType"]!.GetValue<string>());
Assert.Equal("created", json["nests"]![1]!["kind"]!.GetValue<string>());
Assert.Equal("draft", json["nests"]![0]!["nest"]!["status"]!.GetValue<string>());
var page = (await client.GetFromJsonAsync<SetReleasePage>($"/api/sets/{set.Id}/releases", Options))!;
var page = (await client.GetFromJsonAsync<SetReleasePage>($"/api/releases/query?setId={set.Id}", 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));
var byNest = (await client.GetFromJsonAsync<SetReleasePage>($"/api/releases/query?nestId={second}&limit=5", Options))!;
Assert.Equal((release.Id, 2), (byNest.Items.Single().Id, byNest.Items.Single().Sets.Single().Quantity));
Assert.Equal(release.Id, (await client.GetFromJsonAsync<SetReleaseRecord>($"/api/releases/{release.Id}", Options))!.Id);
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);
Assert.Equal(0, (await client.GetFromJsonAsync<SetReleasePage>($"/api/releases/query?nestId={await AddNest(client)}", Options))!.Total);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/releases/{Guid.NewGuid()}")).StatusCode);
foreach (var query in new[] { "limit=0", "limit=201", "offset=-1", "setId=x", $"setId={Guid.Empty}", "nestId=1&nestId=2", "other=1" })
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/releases/query?{query}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/sets/{set.Id}/releases")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/nests/{first}/release")).StatusCode);
}
[Fact]
public async Task Api_Conflicts_ReturnTheReleaseHoldingTheIdOrNest()
public async Task Api_Conflicts_ReturnTheReleaseHoldingTheIdOrCreatedNest()
{
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 owner = Release(set.Id, 1, taken);
using (await client.PostAsync("/api/releases", Json(JsonSerializer.Serialize(owner, Options)))) { }
var reused = Release(1, await AddNest(client));
var reused = Release(set.Id, 1, await AddNest(client));
reused.Id = owner.Id;
foreach (var body in new[] { Release(1, await AddNest(client), taken), reused })
foreach (var body in new[] { Release(set.Id, 1, await AddNest(client), taken), reused })
{
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(JsonSerializer.Serialize(body, Options)));
using var response = await client.PostAsync("/api/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);
Assert.Equal(1, (await client.GetFromJsonAsync<SetReleasePage>($"/api/releases/query?setId={set.Id}", Options))!.Total);
}
[Fact]
public async Task Api_Undo_ReturnsTheUndoneRelease_OrWhyNot()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var set = await Create(client, Set());
var nest = await AddNest(client);
var release = Release(set.Id, 1, nest);
using (await client.PostAsync("/api/releases", Json(JsonSerializer.Serialize(release, Options)))) { }
using var undone = await client.PostAsync($"/api/releases/{release.Id}/undo", Json("""{ "undoneBy": "Synthetic undoer" }"""));
Assert.Equal(HttpStatusCode.OK, undone.StatusCode);
var json = JsonNode.Parse(await undone.Content.ReadAsStringAsync())!;
Assert.Equal("Synthetic undoer", json["undoneBy"]!.GetValue<string>());
Assert.Equal("deleted", json["nests"]![0]!["undo"]!.GetValue<string>());
using var again = await client.PostAsync($"/api/releases/{release.Id}/undo", Json("{}"));
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
Assert.Equal("alreadyUndone", JsonNode.Parse(await again.Content.ReadAsStringAsync())!["reason"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync($"/api/releases/{Guid.NewGuid()}/undo", Json("{}"))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync($"/api/releases/{release.Id}/undo", Json("[1]"))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync($"/api/releases/{release.Id}/undo",
Json($$"""{ "skipNests": ["{{Guid.Empty}}"] }"""))).StatusCode);
}
public static TheoryData<string, string> RejectedReleases()
{
var valid = JsonSerializer.Serialize(Release(1, NestA), Options);
var valid = JsonSerializer.Serialize(Release(SetA, 1, NestA), Options);
string With(string property, JsonNode? value)
{
var node = JsonNode.Parse(valid)!.AsObject();
node[property] = value;
return node.ToJsonString();
}
string WithSet(string property, JsonNode? value)
{
var node = JsonNode.Parse(valid)!.AsObject();
node["sets"]![0]![property] = value;
return node.ToJsonString();
}
string WithNest(string property, JsonNode? value)
{
var node = JsonNode.Parse(valid)!.AsObject();
@@ -301,11 +515,14 @@ public sealed class SetReleaseTests
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) },
{ "missing sets", Without("sets") },
{ "no sets", With("sets", new JsonArray()) },
{ "unknown set", WithSet("setId", Guid.NewGuid().ToString()) },
{ "zero set revision", WithSet("setRevision", 0) },
{ "unknown set revision", WithSet("setRevision", 2) },
{ "zero quantity", WithSet("quantity", 0) },
{ "quantity over the limit", WithSet("quantity", 1001) },
{ "repeated set", With("sets", new JsonArray(JsonNode.Parse(valid)!["sets"]![0]!.DeepClone(), JsonNode.Parse(valid)!["sets"]![0]!.DeepClone())) },
{ "long notes", With("notes", new string('x', 4001)) },
{ "null released by", With("releasedBy", null) },
{ "missing nests", Without("nests") },
@@ -314,6 +531,11 @@ public sealed class SetReleaseTests
{ "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 kind", WithNest("kind", 1) },
{ "unknown kind", WithNest("kind", "copied") },
{ "created with a before revision", WithNest("revisionBefore", 1) },
{ "merged without a before revision", WithNest("kind", "merged") },
{ "zero after revision", WithNest("revisionAfter", 0) },
{ "integer units", WithNest("units", 0) },
{ "unknown units", WithNest("units", "Feet") },
{ "combined cut type", WithNest("cutType", "Laser, Plasma") },
@@ -321,12 +543,16 @@ public sealed class SetReleaseTests
{ "negative thickness", WithNest("thickness", -1) },
{ "null material", WithNest("materialName", null) },
{ "long grade", WithNest("materialGrade", new string('g', 201)) },
{ "null items", WithNest("items", null) },
{ "item of another set", WithNest("items", JsonNode.Parse($$"""[{ "setId": "{{Guid.NewGuid()}}", "drawingId": "{{Guid.NewGuid()}}", "drawingRevision": 1, "quantity": 1 }]""")) },
{ "item with no quantity", WithNest("items", JsonNode.Parse($$"""[{ "setId": "{{SetA}}", "drawingId": "{{Guid.NewGuid()}}", "drawingRevision": 1, "quantity": 0 }]""")) },
{ "not an object", "[1]" },
{ "malformed", "{" },
};
}
private static readonly Guid NestA = Guid.Parse("00000000-0000-0000-0000-0000000000a1");
private static readonly Guid SetA = Guid.Parse("00000000-0000-0000-0000-0000000000b1");
[Theory]
[MemberData(nameof(RejectedReleases))]
@@ -334,27 +560,33 @@ public sealed class SetReleaseTests
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var set = await Create(client, Set());
await Create(client, Set(id: SetA));
await AddNest(client, id: NestA);
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(json));
using var response = await client.PostAsync("/api/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);
Assert.Equal(0, (await client.GetFromJsonAsync<SetReleasePage>("/api/releases/query", Options))!.Total);
}
[Fact]
public async Task Api_JoinedNestInTheBody_IsIgnored()
public async Task Api_JoinedAndServerComputedValuesInTheBody_AreIgnored()
{
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))!;
var node = JsonNode.Parse(JsonSerializer.Serialize(Release(set.Id, 1, await AddNest(client)), Options))!;
node["nests"]![0]!["nest"] = new JsonObject { ["status"] = 99, ["name"] = 5 };
node["nests"]![0]!["undo"] = 7;
node["sets"]![0]!["setName"] = 3;
node["undoneAt"] = "2026-10-21T09:00:00Z";
node["undoneBy"] = 4;
using var response = await client.PostAsync($"/api/sets/{set.Id}/releases", Json(node.ToJsonString()));
using var response = await client.PostAsync("/api/releases", Json(node.ToJsonString()));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var stored = (await response.Content.ReadFromJsonAsync<SetReleaseRecord>(Options))!;
Assert.Equal((false, "Synthetic kit", (SetReleaseNestUndo?)null), (stored.IsUndone, stored.Sets[0].SetName, stored.Nests[0].Undo));
}
private static object? Scalar(string path, string sql)
@@ -365,4 +597,13 @@ public sealed class SetReleaseTests
command.CommandText = sql;
return command.ExecuteScalar();
}
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();
}
}
+3 -73
View File
@@ -1,7 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using OpenNest;
using OpenNest.Data;
namespace OpenNest.Server;
@@ -23,13 +21,6 @@ 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();
@@ -136,77 +127,16 @@ internal static class DrawingSetRequest
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) =>
internal 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)
internal 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)
internal static bool RemoveJoined(JsonObject root, string list, string joined)
{
if (!root.Any(p => string.Equals(p.Key, list, StringComparison.OrdinalIgnoreCase)))
return false;
+3 -41
View File
@@ -4,8 +4,8 @@ using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// <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
/// <c>/api/sets</c>: named, versioned lists of library drawings with quantities, as JSON
/// (their releases are <see cref="ReleaseRoutes"/>). Existing-set
/// writes follow the drawing If-Match contract (428 missing, 400 malformed, 409 stale or
/// wrong state with the current record).
/// </summary>
@@ -69,35 +69,6 @@ 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)
@@ -124,16 +95,7 @@ internal static class DrawingSetRoutes
: (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)
internal static async Task<(string? Json, IResult? Error)> ReadJson(HttpRequest request, string what)
{
if (!request.HasJsonContentType())
return (null, Results.BadRequest($"Expected an application/json {what}."));
+409 -79
View File
@@ -4,15 +4,17 @@ 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.
/// Release storage. <c>releases</c> records who released and whether it was undone;
/// <c>release_sets</c> which set revisions were released and how many kits of each;
/// <c>release_nests</c> the nests each release created or merged into, with the nest
/// revision before and after; <c>release_nest_items</c> the drawing quantities each set
/// added to each nest. A created nest belongs to that release only; a merged nest may be in
/// several. 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 = """
// The v7 layout. Still created by the v7 step so every database reaches v9 by one path.
private const string SetReleaseTablesV7 = """
CREATE TABLE set_releases (
id TEXT NOT NULL PRIMARY KEY,
setId TEXT NOT NULL,
@@ -36,20 +38,87 @@ public sealed partial class NestDatabase
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
// v8 -> v9. A v7 release becomes one set line (its multiplier is the quantity) and its
// nests become created nests at their first revision, which is the one the release
// uploaded. Their item quantities were never recorded, so they have none.
private const string ReleaseUpgradeToV9 = """
CREATE TABLE releases (
id TEXT NOT NULL PRIMARY KEY,
notes TEXT NOT NULL DEFAULT '',
releasedAt TEXT NOT NULL,
releasedBy TEXT NOT NULL DEFAULT '',
undoneAt TEXT,
undoneBy TEXT NOT NULL DEFAULT '');
CREATE TABLE release_sets (
releaseId TEXT NOT NULL REFERENCES releases(id),
position INTEGER NOT NULL,
setId TEXT NOT NULL,
setRevision INTEGER NOT NULL,
quantity INTEGER NOT NULL,
PRIMARY KEY (releaseId, position),
UNIQUE (releaseId, setId),
FOREIGN KEY (setId, setRevision) REFERENCES drawing_set_revisions(id, revision));
CREATE TABLE release_nests (
releaseId TEXT NOT NULL REFERENCES releases(id),
position INTEGER NOT NULL,
nestId TEXT NOT NULL REFERENCES nests(id),
kind TEXT NOT NULL,
revisionBefore INTEGER,
revisionAfter INTEGER NOT NULL,
undo TEXT,
undoRevision INTEGER,
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),
UNIQUE (releaseId, nestId));
CREATE TABLE release_nest_items (
releaseId TEXT NOT NULL,
nestId TEXT NOT NULL,
position INTEGER NOT NULL,
setId TEXT NOT NULL,
drawingId TEXT NOT NULL,
drawingRevision INTEGER NOT NULL,
quantity INTEGER NOT NULL,
PRIMARY KEY (releaseId, nestId, position),
UNIQUE (releaseId, nestId, setId, drawingId),
FOREIGN KEY (releaseId, nestId) REFERENCES release_nests(releaseId, nestId));
CREATE INDEX ix_releases_releasedAt ON releases(releasedAt DESC);
CREATE INDEX ix_release_sets_set ON release_sets(setId);
CREATE INDEX ix_release_nests_nest ON release_nests(nestId);
INSERT INTO releases (id, notes, releasedAt, releasedBy)
SELECT id, notes, releasedAt, releasedBy FROM set_releases;
INSERT INTO release_sets (releaseId, position, setId, setRevision, quantity)
SELECT id, 0, setId, setRevision, multiplier FROM set_releases;
INSERT INTO release_nests (releaseId, position, nestId, kind, revisionBefore, revisionAfter,
materialName, materialGrade, thickness, units, cutType)
SELECT r.releaseId, r.position, r.nestId, 'Created', NULL,
COALESCE((SELECT MIN(v.revision) FROM nest_revisions v WHERE v.id = r.nestId), 1),
r.materialName, r.materialGrade, r.thickness, r.units, r.cutType
FROM set_release_nests r;
DROP INDEX ix_set_release_nests_nest;
DROP INDEX ix_set_releases_set;
DROP TABLE set_release_nests;
DROP TABLE set_releases;
PRAGMA user_version=9;
""";
private const string ReleaseSelect =
"SELECT x.id, x.notes, x.releasedAt, x.releasedBy, x.undoneAt, x.undoneBy FROM releases x";
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.
/// Records a release whose nests already exist. Set revisions must exist but need not be
/// current, and sets may be archived: the nests already exist, so a release is never
/// refused for a state the operator cannot fix. A merged nest need not still be at the
/// revision the release wrote.
/// </summary>
public SetReleaseRecord? InsertSetRelease(Guid setId, SetReleaseRecord release)
public SetReleaseRecord InsertRelease(SetReleaseRecord release)
{
ArgumentNullException.ThrowIfNull(release);
if (release.Id == Guid.Empty) throw new ArgumentException("A release id is required.");
@@ -60,17 +129,17 @@ public sealed partial class NestDatabase
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())
foreach (var line in release.Sets)
{
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}.");
if (!Exists("SELECT 1 FROM drawing_set_revisions WHERE id=$id AND revision=$revision", line.SetId, line.SetRevision))
{
throw new ArgumentException(GetDrawingSet(line.SetId) is null
? $"Set {line.SetId} does not exist."
: $"Set {line.SetId} has no revision {line.SetRevision}.");
}
}
foreach (var nest in release.Nests)
@@ -82,68 +151,208 @@ public sealed partial class NestDatabase
: $"Nest {nest.NestId} is deleted.");
}
if (ReleaseForNest(nest.NestId) is { } owner)
throw new SetReleaseConflictException(owner, $"Nest {nest.NestId} already belongs to a release.");
foreach (var revision in new[] { nest.RevisionBefore, nest.RevisionAfter }.OfType<int>())
{
if (!Exists("SELECT 1 FROM nest_revisions WHERE id=$id AND revision=$revision", nest.NestId, revision))
throw new ArgumentException($"Nest {nest.NestId} has no revision {revision}.");
}
// A created nest came from this release alone; only merges share a nest.
if (nest.Kind == SetReleaseNestKind.Created && ReleasesOfNest(nest.NestId) is [var owner, ..])
throw new SetReleaseConflictException(ReadRelease(owner)!, $"Nest {nest.NestId} already belongs to a release.");
}
WriteRelease(setId, release, transaction);
WriteRelease(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)
public SetReleaseRecord? GetRelease(Guid id)
{
if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
throw new ArgumentOutOfRangeException(nameof(limit));
lock (_sync)
return ReadRelease(id);
}
/// <summary>
/// One page of releases, newest first, optionally only those that include
/// <paramref name="setId"/> or <paramref name="nestId"/>.
/// </summary>
public SetReleasePage QueryReleases(Guid? setId, Guid? nestId, int offset, int limit)
{
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit));
var filters = new List<string>();
if (setId is not null)
filters.Add("EXISTS (SELECT 1 FROM release_sets s WHERE s.releaseId = x.id AND s.setId = $setId)");
if (nestId is not null)
filters.Add("EXISTS (SELECT 1 FROM release_nests r WHERE r.releaseId = x.id AND r.nestId = $nestId)");
var where = filters.Count == 0 ? "" : "WHERE " + string.Join(" AND ", filters);
void AddFilters(SqliteCommand command)
{
if (setId is not null) command.Parameters.AddWithValue("$setId", setId.Value.ToString());
if (nestId is not null) command.Parameters.AddWithValue("$nestId", nestId.Value.ToString());
}
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());
count.CommandText = $"SELECT COUNT(*) FROM releases x {where}";
AddFilters(count);
var total = checked((int)(long)count.ExecuteScalar()!);
using var command = _connection.CreateCommand();
command.CommandText = $"""
{ReleaseSelect} WHERE x.setId=$id
{ReleaseSelect} {where}
ORDER BY x.releasedAt DESC, x.id DESC LIMIT $limit OFFSET $offset
""";
command.Parameters.AddWithValue("$id", setId.ToString());
AddFilters(command);
command.Parameters.AddWithValue("$limit", limit);
command.Parameters.AddWithValue("$offset", offset);
var releases = ReadReleases(command);
foreach (var release in releases)
release.Nests = ReadReleaseNests(release.Id);
Complete(release);
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)
/// <summary>
/// Undoes a release in one transaction: each created nest is deleted and each merged nest
/// restored to the revision before the merge, except the nests the request skips. Every
/// other nest must still hold the revision the release wrote (at it, or restored to it);
/// otherwise nothing changes and <see cref="ReleaseUndoRefusedException"/> lists the
/// nests that moved on. So releases sharing a nest are undone newest first.
/// Returns null for an unknown release.
/// </summary>
public SetReleaseRecord? UndoRelease(Guid id, SetReleaseUndoRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var error = SetReleaseRules.GetUndoError(request);
if (error is not null)
throw new ArgumentException(error);
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;
using var transaction = _connection.BeginTransaction();
var release = ReadRelease(id);
if (release is null) return null;
if (release.IsUndone)
throw new ReleaseUndoRefusedException(new SetReleaseUndoConflict
{
Reason = SetReleaseUndoRefusal.AlreadyUndone,
Release = release,
});
var skipped = request.SkipNests.ToHashSet();
foreach (var skip in skipped)
{
if (release.Nests.All(n => n.NestId != skip))
throw new ArgumentException($"Nest {skip} is not part of this release.");
}
// A nest still holds what the release wrote when it is at that revision, or when its
// latest save restored it (such as undoing a later release that merged into it).
var expected = new Dictionary<Guid, int>();
var changed = new List<NestRecord>();
foreach (var nest in release.Nests.Where(n => !skipped.Contains(n.NestId)))
{
var current = Get(nest.NestId) ?? Get(nest.NestId, deleted: true);
if (current is { Deleted: false }
&& (current.Revision == nest.RevisionAfter || current.RestoredFromRevision == nest.RevisionAfter))
expected[nest.NestId] = current.Revision;
else
changed.Add(current ?? new NestRecord { Id = nest.NestId, Deleted = true });
}
if (changed.Count > 0)
throw new ReleaseUndoRefusedException(new SetReleaseUndoConflict
{
Reason = SetReleaseUndoRefusal.NestsChanged,
Release = release,
ChangedNests = changed,
});
using var mark = _connection.CreateCommand();
mark.Transaction = transaction;
mark.CommandText = "UPDATE release_nests SET undo=$undo, undoRevision=$revision WHERE releaseId=$id AND nestId=$nest";
mark.Parameters.AddWithValue("$id", id.ToString());
var undo = mark.Parameters.Add("$undo", SqliteType.Text);
var revision = mark.Parameters.Add("$revision", SqliteType.Integer);
var nestId = mark.Parameters.Add("$nest", SqliteType.Text);
foreach (var nest in release.Nests)
{
SetReleaseNestUndo outcome;
if (skipped.Contains(nest.NestId))
{
outcome = SetReleaseNestUndo.Skipped;
}
else if (nest.Kind == SetReleaseNestKind.Created)
{
if (!MarkDeleted(nest.NestId, true, expected[nest.NestId], transaction))
throw new InvalidOperationException($"Nest {nest.NestId} could not be deleted.");
outcome = SetReleaseNestUndo.Deleted;
}
else
{
if (!RestoreRevision(nest.NestId, nest.RevisionBefore!.Value, expected[nest.NestId], transaction))
throw new InvalidOperationException($"Nest {nest.NestId} has no revision {nest.RevisionBefore}.");
outcome = SetReleaseNestUndo.Restored;
}
undo.Value = outcome.ToString();
revision.Value = outcome == SetReleaseNestUndo.Skipped ? DBNull.Value : checked(expected[nest.NestId] + 1);
nestId.Value = nest.NestId.ToString();
mark.ExecuteNonQuery();
}
mark.Parameters.Clear();
mark.CommandText = "UPDATE releases SET undoneAt=$at, undoneBy=$by WHERE id=$id";
mark.Parameters.AddWithValue("$id", id.ToString());
mark.Parameters.AddWithValue("$at", DateTime.Now.ToString("o"));
mark.Parameters.AddWithValue("$by", request.UndoneBy);
mark.ExecuteNonQuery();
transaction.Commit();
return ReadRelease(id);
}
}
// Called only under _sync. The releases that include the nest, newest first.
private List<Guid> ReleasesOfNest(Guid nestId)
{
using var command = _connection.CreateCommand();
command.CommandText = """
SELECT x.id FROM releases x JOIN release_nests r ON r.releaseId = x.id
WHERE r.nestId=$id ORDER BY x.releasedAt DESC, x.id DESC
""";
command.Parameters.AddWithValue("$id", nestId.ToString());
var ids = new List<Guid>();
using var reader = command.ExecuteReader();
while (reader.Read())
ids.Add(Guid.Parse(reader.GetString(0)));
return ids;
}
// Called only under _sync.
private bool Exists(string sql, Guid id, int revision)
{
using var command = _connection.CreateCommand();
command.CommandText = sql;
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$revision", revision);
return command.ExecuteScalar() is not null;
}
// Called only under _sync with the caller's transaction.
private void WriteRelease(Guid setId, SetReleaseRecord release, SqliteTransaction transaction)
private void WriteRelease(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)
INSERT INTO releases (id, notes, releasedAt, releasedBy)
VALUES ($id, $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);
@@ -151,12 +360,37 @@ public sealed partial class NestDatabase
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)
INSERT INTO release_sets (releaseId, position, setId, setRevision, quantity)
VALUES ($id, $position, $set, $revision, $quantity)
""";
command.Parameters.AddWithValue("$id", release.Id.ToString());
var position = command.Parameters.Add("$position", SqliteType.Integer);
var setId = command.Parameters.Add("$set", SqliteType.Text);
var setRevision = command.Parameters.Add("$revision", SqliteType.Integer);
var quantity = command.Parameters.Add("$quantity", SqliteType.Integer);
for (var i = 0; i < release.Sets.Count; i++)
{
var line = release.Sets[i];
position.Value = i;
setId.Value = line.SetId.ToString();
setRevision.Value = line.SetRevision;
quantity.Value = line.Quantity;
command.ExecuteNonQuery();
}
command.Parameters.Clear();
command.CommandText = """
INSERT INTO release_nests (releaseId, position, nestId, kind, revisionBefore, revisionAfter,
materialName, materialGrade, thickness, units, cutType)
VALUES ($id, $position, $nest, $kind, $before, $after,
$materialName, $materialGrade, $thickness, $units, $cutType)
""";
command.Parameters.AddWithValue("$id", release.Id.ToString());
position = command.Parameters.Add("$position", SqliteType.Integer);
var nestId = command.Parameters.Add("$nest", SqliteType.Text);
var kind = command.Parameters.Add("$kind", SqliteType.Text);
var before = command.Parameters.Add("$before", SqliteType.Integer);
var after = command.Parameters.Add("$after", SqliteType.Integer);
var materialName = command.Parameters.Add("$materialName", SqliteType.Text);
var materialGrade = command.Parameters.Add("$materialGrade", SqliteType.Text);
var thickness = command.Parameters.Add("$thickness", SqliteType.Real);
@@ -167,6 +401,9 @@ public sealed partial class NestDatabase
var nest = release.Nests[i];
position.Value = i;
nestId.Value = nest.NestId.ToString();
kind.Value = nest.Kind.ToString();
before.Value = (object?)nest.RevisionBefore ?? DBNull.Value;
after.Value = nest.RevisionAfter;
materialName.Value = nest.MaterialName;
materialGrade.Value = nest.MaterialGrade;
thickness.Value = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
@@ -174,6 +411,33 @@ public sealed partial class NestDatabase
cutType.Value = nest.CutType.ToString();
command.ExecuteNonQuery();
}
command.Parameters.Clear();
command.CommandText = """
INSERT INTO release_nest_items (releaseId, nestId, position, setId, drawingId, drawingRevision, quantity)
VALUES ($id, $nest, $position, $set, $drawing, $revision, $quantity)
""";
command.Parameters.AddWithValue("$id", release.Id.ToString());
nestId = command.Parameters.Add("$nest", SqliteType.Text);
position = command.Parameters.Add("$position", SqliteType.Integer);
setId = command.Parameters.Add("$set", SqliteType.Text);
var drawingId = command.Parameters.Add("$drawing", SqliteType.Text);
var drawingRevision = command.Parameters.Add("$revision", SqliteType.Integer);
quantity = command.Parameters.Add("$quantity", SqliteType.Integer);
foreach (var nest in release.Nests)
{
nestId.Value = nest.NestId.ToString();
for (var i = 0; i < nest.Items.Count; i++)
{
var item = nest.Items[i];
position.Value = i;
setId.Value = item.SetId.ToString();
drawingId.Value = item.DrawingId.ToString();
drawingRevision.Value = item.DrawingRevision;
quantity.Value = item.Quantity;
command.ExecuteNonQuery();
}
}
}
// Called only under _sync.
@@ -184,7 +448,7 @@ public sealed partial class NestDatabase
command.Parameters.AddWithValue("$id", releaseId.ToString());
var release = ReadReleases(command).FirstOrDefault();
if (release is not null)
release.Nests = ReadReleaseNests(release.Id);
Complete(release);
return release;
}
@@ -197,50 +461,116 @@ public sealed partial class NestDatabase
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),
Notes = reader.GetString(1),
ReleasedAt = DateTime.Parse(reader.GetString(2)),
ReleasedBy = reader.GetString(3),
UndoneAt = reader.IsDBNull(4) ? null : DateTime.Parse(reader.GetString(4)),
UndoneBy = reader.GetString(5),
});
}
return releases;
}
// Called only under _sync. Each nest joined to its current metadata, deleted or not.
private List<SetReleaseNest> ReadReleaseNests(Guid releaseId)
// Called only under _sync. Reads the release's sets (named as at their revision) and its
// nests, each joined to its current metadata, deleted or not, with its items.
private void Complete(SetReleaseRecord release)
{
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())
var id = release.Id.ToString();
using (var command = _connection.CreateCommand())
{
nests.Add(new SetReleaseNest
command.CommandText = """
SELECT s.setId, s.setRevision, s.quantity, COALESCE(v.name, '')
FROM release_sets s LEFT JOIN drawing_set_revisions v ON v.id = s.setId AND v.revision = s.setRevision
WHERE s.releaseId=$id ORDER BY s.position
""";
command.Parameters.AddWithValue("$id", id);
var sets = new List<SetReleaseLine>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
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),
});
sets.Add(new SetReleaseLine
{
SetId = Guid.Parse(reader.GetString(0)),
SetRevision = reader.GetInt32(1),
Quantity = reader.GetInt32(2),
SetName = reader.GetString(3),
});
}
release.Sets = sets;
}
var items = new Dictionary<Guid, List<SetReleaseNestItem>>();
using (var command = _connection.CreateCommand())
{
command.CommandText = """
SELECT nestId, setId, drawingId, drawingRevision, quantity
FROM release_nest_items WHERE releaseId=$id ORDER BY nestId, position
""";
command.Parameters.AddWithValue("$id", id);
using var reader = command.ExecuteReader();
while (reader.Read())
{
var nestId = Guid.Parse(reader.GetString(0));
if (!items.TryGetValue(nestId, out var list))
items[nestId] = list = new List<SetReleaseNestItem>();
list.Add(new SetReleaseNestItem
{
SetId = Guid.Parse(reader.GetString(1)),
DrawingId = Guid.Parse(reader.GetString(2)),
DrawingRevision = reader.GetInt32(3),
Quantity = reader.GetInt32(4),
});
}
}
using (var command = _connection.CreateCommand())
{
command.CommandText = $"""
SELECT r.nestId, r.kind, r.revisionBefore, r.revisionAfter, r.undo, r.undoRevision,
r.materialName, r.materialGrade, r.thickness, r.units, r.cutType, {JoinedNestColumns}
FROM release_nests r LEFT JOIN nests n ON n.id = r.nestId
WHERE r.releaseId=$id ORDER BY r.position
""";
command.Parameters.AddWithValue("$id", id);
var nests = new List<SetReleaseNest>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var nestId = Guid.Parse(reader.GetString(0));
nests.Add(new SetReleaseNest
{
NestId = nestId,
Kind = Enum.Parse<SetReleaseNestKind>(reader.GetString(1)),
RevisionBefore = reader.IsDBNull(2) ? null : reader.GetInt32(2),
RevisionAfter = reader.GetInt32(3),
Undo = reader.IsDBNull(4) ? null : Enum.Parse<SetReleaseNestUndo>(reader.GetString(4)),
UndoRevision = reader.IsDBNull(5) ? null : reader.GetInt32(5),
MaterialName = reader.GetString(6),
MaterialGrade = reader.GetString(7),
Thickness = reader.GetDouble(8),
Units = Enum.Parse<Units>(reader.GetString(9)),
CutType = Enum.Parse<CutType>(reader.GetString(10)),
Items = items.TryGetValue(nestId, out var list) ? list : Array.Empty<SetReleaseNestItem>(),
Nest = reader.IsDBNull(11) ? null : ReadRecord(reader, first: 11),
});
}
release.Nests = nests;
}
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>
/// <summary>The release that already holds the id or the created nest.</summary>
public SetReleaseRecord Current { get; } = current;
}
/// <summary>An undo was refused; nothing was changed.</summary>
public sealed class ReleaseUndoRefusedException(SetReleaseUndoConflict conflict)
: Exception(conflict.Reason == SetReleaseUndoRefusal.AlreadyUndone
? "The release was already undone."
: "Nests of the release have changed since it was made.")
{
public SetReleaseUndoConflict Conflict { get; } = conflict;
}
+1 -1
View File
@@ -39,7 +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}
{SetReleaseTablesV7}
PRAGMA user_version=7;
""";
+51 -29
View File
@@ -223,49 +223,65 @@ public sealed partial class NestDatabase : IDisposable
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var current = Get(id);
if (current is null) return null;
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
NestRecord historical;
using (var command = _connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id AND revision=$revision";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$revision", sourceRevision);
using var reader = command.ExecuteReader();
if (!reader.Read()) return null;
historical = ReadRecord(reader);
}
// Restore is a deliberate new active revision, including when the bytes already match.
WriteRevision(id, historical, GetRevisionFile(id, sourceRevision)!, current.Revision, sourceRevision, transaction);
if (!RestoreRevision(id, sourceRevision, expectedRevision, transaction)) return null;
transaction.Commit();
return Get(id);
}
}
// Called only under _sync with the caller's transaction (release undo shares one).
// False when the active nest or the revision is unknown.
private bool RestoreRevision(Guid id, int sourceRevision, int expectedRevision, SqliteTransaction transaction)
{
var current = Get(id);
if (current is null) return false;
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
NestRecord historical;
using (var command = _connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id AND revision=$revision";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$revision", sourceRevision);
using var reader = command.ExecuteReader();
if (!reader.Read()) return false;
historical = ReadRecord(reader);
}
// Restore is a deliberate new active revision, including when the bytes already match.
WriteRevision(id, historical, GetRevisionFile(id, sourceRevision)!, current.Revision, sourceRevision, transaction);
return true;
}
public NestRecord? SetDeleted(Guid id, bool deleted, int expectedRevision)
{
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var current = Get(id, !deleted);
if (current is null) return null;
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "UPDATE nests SET deleted=$deleted, revision=$revision, savedAt=$savedAt, restoredFromRevision=NULL WHERE id=$id";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$revision", checked(current.Revision + 1));
command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o"));
command.ExecuteNonQuery();
Snapshot(id, transaction);
if (!MarkDeleted(id, deleted, expectedRevision, transaction)) return null;
transaction.Commit();
return Get(id, deleted);
}
}
// Called only under _sync with the caller's transaction (release undo shares one).
// False when no nest is in the opposite state.
private bool MarkDeleted(Guid id, bool deleted, int expectedRevision, SqliteTransaction transaction)
{
var current = Get(id, !deleted);
if (current is null) return false;
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "UPDATE nests SET deleted=$deleted, revision=$revision, savedAt=$savedAt, restoredFromRevision=NULL WHERE id=$id";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$revision", checked(current.Revision + 1));
command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o"));
command.ExecuteNonQuery();
Snapshot(id, transaction);
return true;
}
public NestPage? History(Guid id, int offset, int limit, bool deleted = false)
{
if (offset < 0 || limit is < 1 or > 200) throw new ArgumentOutOfRangeException(nameof(limit));
@@ -325,7 +341,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 8) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 9) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -392,6 +408,12 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = "PRAGMA user_version=8";
command.ExecuteNonQuery();
}
if (version < 9)
{
// Releases cover several sets and may merge into existing nests.
command.CommandText = ReleaseUpgradeToV9;
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
+1
View File
@@ -141,6 +141,7 @@ app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, i
app.MapDrawingLibrary();
app.MapDrawingSets();
app.MapReleases();
app.Run();
+172
View File
@@ -0,0 +1,172 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using OpenNest;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// Parses release requests with the drawing library's strictness: unknown or repeated query
/// parameters and enum numbers or combinations are rejected, and server-computed or joined
/// properties in a posted release are ignored, so a record read from the server may be sent back.
/// </summary>
internal static class ReleaseRequest
{
private static readonly string[] QueryKeys = { "setId", "nestId", "offset", "limit" };
// Integer enum values are refused; names are additionally checked before deserializing.
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false) },
};
public static bool TryParseQuery(IQueryCollection values, out Guid? setId, out Guid? nestId,
out int offset, out int limit, out string error)
{
setId = null;
nestId = null;
offset = 0;
limit = DrawingLibraryRules.DefaultRevisionLimit;
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;
}
}
if (!TryId(values, "setId", out setId, out error) || !TryId(values, "nestId", out nestId, out error)
|| !DrawingLibraryRequest.TryInteger(values, "offset", ref offset, out error)
|| !DrawingLibraryRequest.TryInteger(values, "limit", ref limit, out error))
return false;
if (offset < 0 || limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit)
{
error = $"offset must be zero or more and limit between 1 and {DrawingLibraryRules.MaxRevisionLimit}.";
return false;
}
return true;
}
/// <summary>
/// Reads and validates one release JSON object. <c>sets</c> and <c>nests</c> must be
/// present, enums must be single names, and joined or server-computed values are dropped.
/// </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 (!DrawingSetRequest.RemoveJoined(root, "sets", "setName"))
{
error = "sets is required.";
return false;
}
if (!DrawingSetRequest.RemoveJoined(root, "nests", "nest"))
{
error = "nests is required.";
return false;
}
Remove(root, "releasedAt", "undoneAt", "undoneBy");
if (DrawingSetRequest.Property(root, "nests") is JsonArray nests)
{
for (var i = 0; i < nests.Count; i++)
{
if (nests[i] is not JsonObject nest)
continue;
Remove(nest, "undo", "undoRevision");
error = NameError<Units>(nest, "units", $"nests[{i}].units must be a supported unit name.")
?? NameError<CutType>(nest, "cutType", $"nests[{i}].cutType must be Laser, Waterjet or Plasma.")
?? NameError<SetReleaseNestKind>(nest, "kind", $"nests[{i}].kind must be created or merged.")
?? "";
if (error.Length > 0)
return false;
}
}
release = root.Deserialize<SetReleaseRecord>(Options) ?? release;
}
catch (JsonException)
{
error = "Invalid release JSON.";
return false;
}
error = SetReleaseRules.GetError(release) ?? "";
return error.Length == 0;
}
public static bool TryReadUndo(string? json, out SetReleaseUndoRequest request, out string error)
{
request = new SetReleaseUndoRequest();
try
{
if (string.IsNullOrWhiteSpace(json) || JsonNode.Parse(json) is not JsonObject root)
{
error = "The undo request must be a JSON object.";
return false;
}
request = root.Deserialize<SetReleaseUndoRequest>(Options) ?? request;
}
catch (JsonException)
{
error = "Invalid undo JSON.";
return false;
}
error = SetReleaseRules.GetUndoError(request) ?? "";
return error.Length == 0;
}
private static bool TryId(IQueryCollection values, string key, out Guid? id, out string error)
{
id = null;
error = "";
if (!values.TryGetValue(key, out var text))
return true;
if (!Guid.TryParse(text.ToString(), out var parsed) || parsed == Guid.Empty)
{
error = $"{key} must be an id.";
return false;
}
id = parsed;
return true;
}
private static string? NameError<TEnum>(JsonObject node, string property, string error)
where TEnum : struct, Enum =>
DrawingSetRequest.Property(node, property) is { } value && !DrawingSetRequest.IsName<TEnum>(value) ? error : null;
private static void Remove(JsonObject node, params string[] names)
{
foreach (var key in node.Select(p => p.Key)
.Where(k => names.Any(n => string.Equals(k, n, StringComparison.OrdinalIgnoreCase))).ToList())
node.Remove(key);
}
}
+56
View File
@@ -0,0 +1,56 @@
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// <c>/api/releases</c>: append-only records of drawing set releases into nests, and their
/// undo. A release is posted after every nest it names is uploaded; its id is allocated by
/// the client, so a post whose reply was lost can be sent again (409 with the stored release).
/// </summary>
internal static class ReleaseRoutes
{
public static void MapReleases(this IEndpointRouteBuilder app)
{
app.MapPost("/api/releases", async (HttpRequest request, NestDatabase db) =>
{
var (json, error) = await DrawingSetRoutes.ReadJson(request, "release");
if (error is not null) return error;
if (!ReleaseRequest.TryReadRelease(json, out var release, out var invalid))
return Results.BadRequest(invalid);
if (release.Id == Guid.Empty)
return Results.BadRequest("id is required: allocate it before posting the release.");
try { return Results.Ok(db.InsertRelease(release)); }
catch (SetReleaseConflictException conflict)
{
return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
}
catch (ArgumentException rejected) { return Results.BadRequest(rejected.Message); }
});
app.MapGet("/api/releases/query", (HttpRequest request, NestDatabase db) =>
ReleaseRequest.TryParseQuery(request.Query, out var setId, out var nestId, out var offset, out var limit, out var error)
? Results.Ok(db.QueryReleases(setId, nestId, offset, limit))
: Results.BadRequest(error));
app.MapGet("/api/releases/{id:guid}", (Guid id, NestDatabase db) =>
db.GetRelease(id) is { } release ? Results.Ok(release) : Results.NotFound());
app.MapPost("/api/releases/{id:guid}/undo", async (Guid id, HttpRequest request, NestDatabase db) =>
{
var (json, error) = await DrawingSetRoutes.ReadJson(request, "undo request");
if (error is not null) return error;
if (!ReleaseRequest.TryReadUndo(json, out var undo, out var invalid))
return Results.BadRequest(invalid);
try
{
var undone = db.UndoRelease(id, undo);
return undone is null ? Results.NotFound() : Results.Ok(undone);
}
catch (ReleaseUndoRefusedException conflict)
{
return Results.Json(conflict.Conflict, statusCode: StatusCodes.Status409Conflict);
}
catch (ArgumentException rejected) { return Results.BadRequest(rejected.Message); }
});
}
}
+20 -12
View File
@@ -116,7 +116,7 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
public int PostAttempts { get; private set; }
public bool FailNextPost { get; set; }
public Task<SetReleaseRecord> PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default)
public Task<SetReleaseRecord> PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default)
{
PostAttempts++;
if (FailNextPost)
@@ -130,10 +130,13 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
var stored = new SetReleaseRecord
{
Id = release.Id,
SetId = setId,
SetRevision = release.SetRevision,
SetName = _revisions[setId].First(r => r.Revision == release.SetRevision).Name,
Multiplier = release.Multiplier,
Sets = release.Sets.Select(s => new SetReleaseLine
{
SetId = s.SetId,
SetRevision = s.SetRevision,
Quantity = s.Quantity,
SetName = _revisions[s.SetId].First(r => r.Revision == s.SetRevision).Name,
}).ToList(),
Notes = release.Notes,
ReleasedBy = release.ReleasedBy,
ReleasedAt = new DateTime(2026, 10, 20, 15, 0, 0, DateTimeKind.Utc),
@@ -143,12 +146,17 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
return Task.FromResult(stored);
}
public Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default)
public Task<SetReleaseRecord?> GetReleaseAsync(Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult(Releases.FirstOrDefault(r => r.Id == id));
public Task<SetReleasePage> QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default)
{
if (!_revisions.ContainsKey(setId))
return Task.FromResult<SetReleasePage?>(null);
var newest = Enumerable.Reverse(Releases.Where(r => r.SetId == setId).ToList()).ToList();
return Task.FromResult<SetReleasePage?>(new SetReleasePage
var newest = Enumerable.Reverse(Releases
.Where(r => setId is null || r.Sets.Any(s => s.SetId == setId))
.Where(r => nestId is null || r.Nests.Any(n => n.NestId == nestId))
.ToList()).ToList();
return Task.FromResult(new SetReleasePage
{
Items = newest.Skip(offset).Take(limit).ToList(),
Total = newest.Count,
@@ -157,8 +165,8 @@ internal sealed class InMemoryDrawingSets : IDrawingSetStore
});
}
public Task<SetReleaseRecord?> GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) =>
Task.FromResult(Releases.FirstOrDefault(r => r.Nests.Any(n => n.NestId == nestId)));
public Task<SetReleaseRecord> UndoReleaseAsync(Guid id, SetReleaseUndoRequest request, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("The in-memory store does not hold nests, so it cannot undo a release.");
private Task<DrawingSetRecord> SetArchived(Guid id, int expectedRevision, bool archived)
{
@@ -44,7 +44,9 @@ public class RemoteDrawingSetStoreTests
var query = await Assert.ThrowsAsync<ServerTooOldException>(() => store.QueryAsync(new DrawingSetQuery()));
await Assert.ThrowsAsync<ServerTooOldException>(() => store.GetAsync(Guid.NewGuid()));
await Assert.ThrowsAsync<ServerTooOldException>(() => store.GetReleaseForNestAsync(Guid.NewGuid()));
var releases = await Assert.ThrowsAsync<ServerTooOldException>(() => store.QueryReleasesAsync(nestId: Guid.NewGuid()));
await Assert.ThrowsAsync<ServerTooOldException>(() => store.GetReleaseAsync(Guid.NewGuid()));
Assert.Contains("several drawing sets", releases.Message);
Assert.Contains("drawing sets", query.Message);
}
@@ -69,8 +71,13 @@ public class RemoteDrawingSetStoreTests
duplicate.Members = duplicate.Members.Concat(duplicate.Members).ToList();
await Assert.ThrowsAsync<ArgumentException>(() => store.CreateAsync(duplicate));
await Assert.ThrowsAsync<ArgumentException>(() => store.PostReleaseAsync(Guid.NewGuid(),
new SetReleaseRecord { Id = Guid.NewGuid(), SetRevision = 1, Multiplier = 0 }));
await Assert.ThrowsAsync<ArgumentException>(() => store.PostReleaseAsync(new SetReleaseRecord
{
Id = Guid.NewGuid(),
Sets = new[] { new SetReleaseLine { SetId = Guid.NewGuid(), SetRevision = 1, Quantity = 0 } },
}));
await Assert.ThrowsAsync<ArgumentException>(() => store.UndoReleaseAsync(Guid.NewGuid(),
new SetReleaseUndoRequest { SkipNests = new[] { Guid.Empty } }));
Assert.Empty(handler.Requests);
}
@@ -100,19 +107,17 @@ public class RemoteDrawingSetStoreTests
var release = new SetReleaseRecord
{
Id = Guid.NewGuid(),
SetRevision = 1,
Multiplier = 1,
Nests = new[] { new SetReleaseNest { NestId = Guid.NewGuid(), MaterialName = "Synthetic Steel" } },
Sets = new[] { new SetReleaseLine { SetId = setId, SetRevision = 1, Quantity = 1 } },
Nests = new[] { new SetReleaseNest { NestId = Guid.NewGuid(), RevisionAfter = 1, MaterialName = "Synthetic Steel" } },
};
var handler = new StubHandler(_ => StubHandler.Json(new
{
id = release.Id,
setId,
setRevision = 1,
nests = new[] { new { nestId = Guid.NewGuid() } },
sets = new[] { new { setId, setRevision = 1, quantity = 1 } },
nests = new[] { new { nestId = Guid.NewGuid(), kind = "created", revisionAfter = 1 } },
}));
using var store = Store(handler);
await Assert.ThrowsAsync<IOException>(() => store.PostReleaseAsync(setId, release));
await Assert.ThrowsAsync<IOException>(() => store.PostReleaseAsync(release));
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ public class SetReleasePlanTests
[Theory]
[InlineData(0)]
[InlineData(SetReleaseRules.MaxMultiplier + 1)]
[InlineData(SetReleaseRules.MaxQuantity + 1)]
public void Build_RejectsMultiplierOutOfRange(int multiplier)
{
Assert.Throws<ArgumentOutOfRangeException>(() => SetReleasePlan.Build(Set((Drawing("SYN-A"), 1)), multiplier, false));
+19 -8
View File
@@ -81,9 +81,16 @@ public class SetReleaseSessionTests
var release = Assert.Single(_sets.Posted);
Assert.Same(result.Release, _sets.Stored.Single());
var line = Assert.Single(release.Sets);
Assert.Equal((set.Id, 4, 2, "Synthetic operator", "Synthetic notes"),
(release.SetId, release.SetRevision, release.Multiplier, release.ReleasedBy, release.Notes));
(line.SetId, line.SetRevision, line.Quantity, release.ReleasedBy, release.Notes));
Assert.Equal(result.Groups.Select(g => g.Record!.Id), release.Nests.Select(n => n.NestId));
Assert.All(release.Nests, n => Assert.Equal((SetReleaseNestKind.Created, (int?)null), (n.Kind, n.RevisionBefore)));
Assert.Equal(result.Groups.Select(g => g.Record!.Revision), release.Nests.Select(n => n.RevisionAfter));
var steelItems = release.Nests.Single(n => n.NestId == steel.Record!.Id).Items;
Assert.Equal(new[] { (a.Id, a.Revision, 6), (b.Id, b.Revision, 2) },
steelItems.OrderBy(i => i.Quantity).Reverse().Select(i => (i.DrawingId, i.DrawingRevision, i.Quantity)));
Assert.All(steelItems, i => Assert.Equal(set.Id, i.SetId));
var aluminum = release.Nests.Single(n => n.MaterialName == "Synthetic Aluminum");
Assert.Equal(("Grade A", 0.25, Units.Inches, CutType.Waterjet),
(aluminum.MaterialGrade, aluminum.Thickness, aluminum.Units, aluminum.CutType));
@@ -389,7 +396,7 @@ public class SetReleaseSessionTests
public Queue<Exception> Failures { get; } = new();
public bool ConflictWithPosted { get; set; }
public Task<SetReleaseRecord> PostReleaseAsync(Guid setId, SetReleaseRecord release, CancellationToken cancellationToken = default)
public Task<SetReleaseRecord> PostReleaseAsync(SetReleaseRecord release, CancellationToken cancellationToken = default)
{
Posted.Add(release);
if (Failures.TryDequeue(out var failure))
@@ -397,10 +404,13 @@ public class SetReleaseSessionTests
var stored = new SetReleaseRecord
{
Id = release.Id,
SetId = setId,
SetRevision = release.SetRevision,
Multiplier = release.Multiplier,
SetName = "Synthetic kit",
Sets = release.Sets.Select(s => new SetReleaseLine
{
SetId = s.SetId,
SetRevision = s.SetRevision,
Quantity = s.Quantity,
SetName = "Synthetic kit",
}).ToList(),
Nests = release.Nests,
};
if (ConflictWithPosted)
@@ -418,7 +428,8 @@ public class SetReleaseSessionTests
public Task<DrawingSetRecord> UpdateAsync(Guid id, DrawingSetRecord record, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> ArchiveAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<DrawingSetRecord> RestoreAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleasePage?> GetReleasesAsync(Guid setId, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleaseRecord?> GetReleaseForNestAsync(Guid nestId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleaseRecord?> GetReleaseAsync(Guid id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleasePage> QueryReleasesAsync(Guid? setId = null, Guid? nestId = null, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<SetReleaseRecord> UndoReleaseAsync(Guid id, SetReleaseUndoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}
+23 -3
View File
@@ -46,15 +46,35 @@ public class SetReleaseTextTests
{
var text = SetReleaseText.Origin(new SetReleaseRecord
{
SetName = "Synthetic kit",
SetRevision = 3,
Multiplier = 2,
Sets = new[] { new SetReleaseLine { SetName = "Synthetic kit", SetRevision = 3, Quantity = 2 } },
ReleasedBy = "Synthetic operator",
});
Assert.Equal("Released from set 'Synthetic kit' revision 3 ×2 by Synthetic operator", text);
}
[Fact]
public void Origin_OfAMergeOfSeveralSets_NamesThemAll_AndAnUndo()
{
var nest = Guid.NewGuid();
var release = new SetReleaseRecord
{
Sets = new[]
{
new SetReleaseLine { SetName = "Kit A", SetRevision = 3, Quantity = 2 },
new SetReleaseLine { SetName = "Kit B", SetRevision = 1, Quantity = 1 },
new SetReleaseLine { SetName = "Kit C", SetRevision = 5, Quantity = 4 },
},
Nests = new[] { new SetReleaseNest { NestId = nest, Kind = SetReleaseNestKind.Merged } },
UndoneAt = new DateTime(2026, 10, 21, 9, 0, 0, DateTimeKind.Utc),
};
Assert.Equal("Merged from sets 'Kit A' revision 3 ×2, 'Kit B' revision 1 ×1 and 'Kit C' revision 5 ×4 (undone)",
SetReleaseText.Origin(release, nest));
Assert.StartsWith("Released from sets", SetReleaseText.Origin(release));
Assert.Equal("Undone", SetReleaseText.Progress(release));
}
[Fact]
public void NestDefaults_ConvertedTo_ScalesPlateDefaultsBetweenUnits()
{
@@ -59,7 +59,7 @@ public class SetReleaseFormsTests
Assert.Equal(steel.Record.Id, nests.Uploaded.Single(u => u.Name == steel.Nest.Name).Id);
var release = Assert.Single(sets.Releases);
Assert.Equal((3, "Synthetic run", 2), (release.Multiplier, release.Notes, release.Nests.Count));
Assert.Equal((3, "Synthetic run", 2), (release.Sets.Single().Quantity, release.Notes, release.Nests.Count));
Assert.Same(release, form.Result!.Release);
Assert.StartsWith("Released: 2 nests", Field<Label>(form, "statusLabel").Text);
Assert.All(grid.Rows.Cast<DataGridViewRow>(), r => Assert.StartsWith("Created N26-", (string)r.Cells["statusColumn"].Value));
@@ -167,10 +167,7 @@ public class SetReleaseFormsTests
sets.Releases.Add(new SetReleaseRecord
{
Id = Guid.NewGuid(),
SetId = set.Id,
SetRevision = 1,
SetName = set.Name,
Multiplier = 2,
Sets = new[] { new SetReleaseLine { SetId = set.Id, SetRevision = 1, SetName = set.Name, Quantity = 2 } },
ReleasedBy = "Synthetic operator",
ReleasedAt = new DateTime(2026, 10, 20, 12, 0, 0, DateTimeKind.Utc),
Nests = new[] { Nest("N1", NestStatus.HasBeenCut), Nest("N2", NestStatus.ToBeCut), Nest("N3", NestStatus.Draft, deleted: true) },
+10 -5
View File
@@ -777,8 +777,8 @@ namespace OpenNest.Forms
}
/// <summary>
/// Shows which drawing set release made this nest, when it is a server nest that came
/// from one. Best effort and in the background: Nest Info never waits for the server.
/// Shows which drawing set releases made or merged into this nest, when it is a server
/// nest that came from one. Best effort and in the background: Nest Info never waits for the server.
/// </summary>
private async Task ShowReleaseOriginAsync(EditNestInfoForm form)
{
@@ -789,9 +789,14 @@ namespace OpenNest.Forms
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var release = await NestStorage.CreateDrawingSetStore().GetReleaseForNestAsync(Document.RemoteId, timeout.Token);
if (release != null && !form.IsDisposed)
form.ReleaseOrigin = SetReleaseText.Origin(release);
// A merged nest can be in several releases: name the newest, count the rest.
var releases = await NestStorage.CreateDrawingSetStore()
.QueryReleasesAsync(nestId: Document.RemoteId, limit: 1, cancellationToken: timeout.Token);
if (releases.Items.Count > 0 && !form.IsDisposed)
{
var origin = SetReleaseText.Origin(releases.Items[0], Document.RemoteId);
form.ReleaseOrigin = releases.Total > 1 ? $"{origin} (+{releases.Total - 1} earlier)" : origin;
}
}
catch (Exception ex) when (ex is IOException or System.Net.Http.HttpRequestException
or OperationCanceledException or ArgumentException or System.Text.Json.JsonException)
+7 -8
View File
@@ -1,6 +1,7 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Forms;
@@ -52,14 +53,9 @@ namespace OpenNest.Forms
summaryLabel.Text = "Loading...";
try
{
var loaded = await sets.GetReleasesAsync(set.Id, requestedOffset, PageSize);
var loaded = await sets.QueryReleasesAsync(setId: set.Id, offset: requestedOffset, limit: PageSize);
if (IsDisposed || request != pageRequest)
return;
if (loaded == null)
{
summaryLabel.Text = "This set no longer exists on the server.";
return;
}
page = loaded;
offset = requestedOffset;
@@ -68,12 +64,15 @@ namespace OpenNest.Forms
foreach (var release in loaded.Items)
{
// Tagged before it is added: adding the first row selects it, which shows its nests.
// This set's line; a release of several sets says how many others it had.
var line = release.Sets.First(s => s.SetId == set.Id);
var others = release.Sets.Count - 1;
var row = new DataGridViewRow { Tag = release };
row.CreateCells(releaseGrid,
release.ReleasedAt == default ? "" : release.ReleasedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture),
release.ReleasedBy,
release.SetRevision,
$"×{release.Multiplier}",
line.SetRevision,
others == 0 ? $"×{line.Quantity}" : $"×{line.Quantity} (+{others} other {(others == 1 ? "set" : "sets")})",
release.Nests.Count,
SetReleaseText.Progress(release),
release.Notes);
+63 -32
View File
@@ -402,14 +402,16 @@ or Not started. Cancel stops between uploads, never during one. When every nest
was created but recording failed, the button becomes **Record Release** and
posts the same release again. A dialog runs one release.
**Releases...** lists the selected set's releases, newest first: when, by whom,
the set revision, the kit multiplier, the number of nests, progress such as
"2 of 3 cut" (deleted nests are not counted) and the notes. The selected
**Releases...** lists the releases that include the selected set, newest first:
when, by whom, the set revision, the kits of this set (and how many other sets
the release had), the number of nests, progress such as "2 of 3 cut" (deleted
nests are not counted) or "Undone", and the notes. The selected
release's nests show their material group and current status, or Deleted.
**Nest > Edit** (Nest Info) shows, beside its buttons, the release a server nest
came from ("Released from set 'Kit A' revision 3 ×2 on 10/20/2026 by ..."). It is
read in the background and left blank when the nest came from none or the
**Nest > Edit** (Nest Info) shows, beside its buttons, the newest release that
made or merged into a server nest ("Released from set 'Kit A' revision 3 ×2 on
10/20/2026 by ...", or "Merged from sets ..."), with a count of earlier ones. It
is read in the background and left blank when the nest came from none or the
server cannot be reached.
## Drawing set endpoints
@@ -465,43 +467,70 @@ save, so a record read from the server may be sent back as it is.
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
the set revisions and the nests. A failure part-way leaves ordinary nests and no
release, and nothing is rolled back silently.
Since schema v9 a release covers one or more sets (`release_sets`), and a nest
may be one the release **created** or an existing draft it **merged** into
(`release_nests`, with the nest revision before and after). The drawing
quantities each set added to each nest are kept in `release_nest_items`, so a
kit can still be traced once kits share a nest. The v8 → v9 migration turns each
v7 release into one set line (its multiplier is the quantity) and created nests
at their first revision, with no items.
`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.
ambiguous post can be retried), `sets` (each `{ setId, setRevision, quantity,
setName }`; `setName` is the set's name at that revision, joined on read),
`notes`, `releasedBy`, the server-computed `releasedAt`, `undoneAt` and
`undoneBy`, and `nests`, in order, each `{ nestId, kind, revisionBefore,
revisionAfter, materialName, materialGrade, thickness, units, cutType, items,
undo, undoRevision, nest }`. `kind` is `created` (no `revisionBefore`) or
`merged`; `revisionAfter` is the nest revision the release wrote. `items` are
`{ setId, drawingId, drawingRevision, quantity }`. `undo` and `undoRevision`
are server-computed. `nest` is the nest's current metadata (deleted or not),
joined on every read so status and deletion are always current. Joined and
server-computed values are 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 |
| POST | `/api/releases` | JSON release with a nonempty `id` | The stored release; 400 for an unknown set or revision; 409 with the release that already holds the id or one of the created nests |
| GET | `/api/releases/query?setId=&nestId=&offset=0&limit=100` | — | Release page, newest first, optionally only releases including the set or the nest; limit 1–200 |
| GET | `/api/releases/{id}` | — | The release, or 404 |
| POST | `/api/releases/{id}/undo` | `{ "undoneBy": "...", "skipNests": [...] }` | The undone release; 404 for an unknown release; 409 with `{ reason, release, changedNests }` |
- 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.
- Releases are append-only: there is no edit or delete. Undo marks the release
undone and records what it did to each nest.
- Set revisions must exist but need not be current, and sets 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. A merged nest need not still be at
`revisionAfter` when the release is posted.
- A created nest belongs to that release only; a merged nest may be in several.
- **Undo** runs in one transaction. Each created nest is soft-deleted and each
merged nest restored to `revisionBefore`, except the nests in `skipNests`
(recorded as `skipped`). Every other nest must still hold `revisionAfter`: be
at it, or have been restored to it as its latest save. Otherwise nothing
changes and the 409 lists the changed nests (`reason` `nestsChanged`), so the
caller can skip them and try again. A second undo is refused with
`alreadyUndone`. Because each undo writes a new nest revision, releases that
share a nest are undone newest first.
- Validation (400, no state change): 1–50 `sets`, no set twice, each quantity
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 whose
named revisions exist, with material name and grade at most 200 characters,
`thickness` finite and at least 0 (stored rounded to 4 decimals), and `kind`,
`units` and `cutType` single enum names; each item names one of the release's
sets, at most once per drawing, with a positive revision and quantity.
#### Client and release session
`OpenNest.Data.RemoteDrawingSetStore` is the client for every route above. It
checks `DrawingSetRules`/`SetReleaseRules` before sending, raises
`ServerTooOldException` on a server without sets, `DrawingSetSaveConflictException`
or `SetReleasePostConflictException` (carrying the current record) on 409, and
refuses replies whose id, revision, members or nests differ from what was sent.
`ServerTooOldException` on a server without sets (or, for releases, without
`/api/releases`), `DrawingSetSaveConflictException` or
`SetReleasePostConflictException` (carrying the current record) on 409,
`SetReleaseUndoConflictException` (carrying the refusal) when an undo is
refused, and refuses replies whose id, revision, members, sets or nests differ
from what was sent.
`SetReleasePlan.Build(set, multiplier, includeArchived)` splits a set read from
the server into groups without any I/O. A group is the members whose drawings
@@ -525,7 +554,9 @@ material or a positive thickness goes to one refused "unassigned" group, listed
its id, so one stored before its reply was lost still counts as created; the
first real failure stops the rest.
3. **Post the release** only when every chosen group was created, under a
client-allocated id. When the post fails, the nests stay and
client-allocated id. It names the set once (the multiplier is its quantity),
each nest as created at its uploaded revision, and the drawing revisions and
quantities each nest received. When the post fails, the nests stay and
`RetryReleaseAsync` posts the same id again; a 409 naming that id is adopted
as the stored release.