feat(server): share machines, post settings, clamps and nest defaults

Schema v13 adds shared_settings and shared_setting_revisions behind
/api/settings: one revision-guarded JSON document per kind and key, with
every revision (deletions included) kept with who saved it. Recreating a
deleted document continues its revision count.

OpenNest.Data gains the client (RemoteSharedSettings), a synchronous store
that keeps the last copy read for offline use and refuses writes while the
server is unreachable (SharedSettingsStore), a machine provider over it, and
JSON parse/serialize entry points for clamp settings and nest defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 20:02:08 -04:00
1 parent 9f3c22dc4f
commit 90e2fd2e6e
20 files changed
+1266 -60

No files matched your search

+47 -21
View File
@@ -194,19 +194,39 @@ public sealed class ClampSettings
/// </summary>
public static ClampSettings Load(string path, out ClampSettingsStatus status)
{
var settings = Fallback;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = ClampSettingsStatus.Missing;
return settings;
return Fallback;
}
string json;
try
{
json = File.ReadAllText(path);
}
catch (Exception ex) when (IsUnreadableFile(ex))
{
status = ClampSettingsStatus.Invalid;
return Fallback;
}
return Parse(json, out status);
}
/// <summary>
/// Reads settings from <paramref name="json"/> (a file or the shared copy) the way
/// <see cref="Load(string, out ClampSettingsStatus)"/> reads a file, never throwing.
/// </summary>
public static ClampSettings Parse(string json, out ClampSettingsStatus status)
{
var settings = Fallback;
// Read as a raw document so a missing field (fallback, Ok) is distinguishable from a
// present but unusable one, including an explicit null (fallback, Partial).
Dictionary<string, JsonElement> values;
try
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
using var document = JsonDocument.Parse(json ?? "");
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
status = ClampSettingsStatus.Invalid;
@@ -307,24 +327,7 @@ public sealed class ClampSettings
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var dto = new Dictionary<string, object>
{
["version"] = CurrentVersion,
["enabled"] = Enabled,
["units"] = Units.ToString().ToLowerInvariant(),
["width"] = Width,
["depth"] = Depth,
["removalMinutes"] = RemovalMinutes,
["centers"] = Centers,
};
// Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters;
if (OriginEndCenters.Count > 0)
dto["originEndCenters"] = OriginEndCenters;
if (FarEndCenters.Count > 0)
dto["farEndCenters"] = FarEndCenters;
var json = JsonSerializer.Serialize(dto, JsonOptions);
var json = ToJson();
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
@@ -344,6 +347,29 @@ public sealed class ClampSettings
}
}
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
public string ToJson()
{
var dto = new Dictionary<string, object>
{
["version"] = CurrentVersion,
["enabled"] = Enabled,
["units"] = Units.ToString().ToLowerInvariant(),
["width"] = Width,
["depth"] = Depth,
["removalMinutes"] = RemovalMinutes,
["centers"] = Centers,
};
// Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters;
if (OriginEndCenters.Count > 0)
dto["originEndCenters"] = OriginEndCenters;
if (FarEndCenters.Count > 0)
dto["farEndCenters"] = FarEndCenters;
return JsonSerializer.Serialize(dto, JsonOptions);
}
private static double Scale(Units from, Units to) =>
from == to ? 1
: from == Units.Inches ? MillimetersPerInch
+1 -1
View File
@@ -7,7 +7,7 @@ public class LocalJsonProvider : IDataProvider
{
private readonly string _directory;
private static readonly JsonSerializerOptions JsonOptions = new()
internal static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+57 -27
View File
@@ -95,26 +95,43 @@ public sealed class NestDefaults
out NestDefaultsStatus status
)
{
var defaults = Fallback;
if (Enum.IsDefined(fallbackUnits))
defaults.Units = fallbackUnits;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = NestDefaultsStatus.Missing;
return defaults;
return WithUnits(Fallback, fallbackUnits);
}
NestDefaultsDto? dto;
string json;
try
{
var json = File.ReadAllText(path);
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
json = File.ReadAllText(path);
}
catch (Exception ex) when (IsUnreadableFile(ex))
{
// A file that exists but cannot be read (locked, access-denied)
// or parsed must never block creating a nest.
// must never block creating a nest.
status = NestDefaultsStatus.Invalid;
return WithUnits(Fallback, fallbackUnits);
}
return Parse(json, fallbackUnits, out status);
}
/// <summary>
/// Reads defaults from <paramref name="json"/> (a file or the shared copy) the way
/// <see cref="Load(string, Units, out NestDefaultsStatus)"/> reads a file, never throwing.
/// </summary>
public static NestDefaults Parse(string json, Units fallbackUnits, out NestDefaultsStatus status)
{
var defaults = WithUnits(Fallback, fallbackUnits);
NestDefaultsDto? dto;
try
{
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json ?? "", JsonOptions);
}
catch (Exception ex) when (IsUnreadableFile(ex))
{
// Defaults that cannot be parsed must never block creating a nest.
status = NestDefaultsStatus.Invalid;
return defaults;
}
@@ -162,6 +179,13 @@ public sealed class NestDefaults
return defaults;
}
private static NestDefaults WithUnits(NestDefaults defaults, Units units)
{
if (Enum.IsDefined(units))
defaults.Units = units;
return defaults;
}
/// <summary>
/// Captures the current units and plate defaults from a nest.
/// </summary>
@@ -237,24 +261,7 @@ public sealed class NestDefaults
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var dto = new NestDefaultsDto
{
Version = CurrentVersion,
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
Quadrant = Quadrant,
PartSpacing = PartSpacing,
EdgeSpacing = new SpacingDto
{
Left = EdgeSpacing.Left,
Bottom = EdgeSpacing.Bottom,
Right = EdgeSpacing.Right,
Top = EdgeSpacing.Top,
},
MachineId = MachineId is { } machineId ? JsonSerializer.SerializeToElement(machineId.ToString()) : null,
};
var json = JsonSerializer.Serialize(dto, JsonOptions);
var json = ToJson();
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
@@ -274,6 +281,29 @@ public sealed class NestDefaults
}
}
/// <summary>The defaults as <see cref="Save"/> writes them, for the shared copy.</summary>
public string ToJson()
{
var dto = new NestDefaultsDto
{
Version = CurrentVersion,
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
Quadrant = Quadrant,
PartSpacing = PartSpacing,
EdgeSpacing = new SpacingDto
{
Left = EdgeSpacing.Left,
Bottom = EdgeSpacing.Bottom,
Right = EdgeSpacing.Right,
Top = EdgeSpacing.Top,
},
MachineId = MachineId is { } machineId ? JsonSerializer.SerializeToElement(machineId.ToString()) : null,
};
return JsonSerializer.Serialize(dto, JsonOptions);
}
/// <summary>
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse
/// would also accept numeric strings ("7") and comma-joined names,
+151
View File
@@ -0,0 +1,151 @@
using System.Net;
using System.Net.Http.Json;
namespace OpenNest.Data;
/// <summary>
/// Talks to the settings every workstation shares on the nest server (<c>/api/settings</c>).
/// A server without them raises <see cref="ServerTooOldException"/>; a stale write raises
/// <see cref="SharedSettingSaveConflictException"/>; other failures surface as <see cref="IOException"/>.
/// </summary>
public sealed class RemoteSharedSettings : IDisposable
{
private const string Feature = "shared machine settings";
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
/// <summary>A client that gives up after <paramref name="timeout"/>, so an unreachable server falls back quickly.</summary>
public RemoteSharedSettings(string baseUrl, TimeSpan timeout)
: this(new HttpClient { Timeout = timeout }, baseUrl)
{
_ownsClient = true;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
public RemoteSharedSettings(HttpClient httpClient, string baseUrl)
{
_httpClient = httpClient;
_baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl));
}
private Uri Url(string relative) => new(_baseUri, relative);
private static string Path(string kind, string key) =>
$"api/settings/{Uri.EscapeDataString(kind)}/{Uri.EscapeDataString(key)}";
/// <summary>Every shared settings document.</summary>
public async Task<IReadOnlyList<SharedSettingRecord>> ListAsync(CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url("api/settings"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(Feature);
await NestServerHttp.EnsureSuccessAsync(response, "read the shared settings", cancellationToken);
var records = await response.Content.ReadFromJsonAsync<List<SharedSettingRecord>>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned no shared settings.");
if (records.Any(record => record is null || record.Revision < 1 || string.IsNullOrEmpty(record.Json)))
throw new IOException("Nest server returned invalid shared settings.");
// A newer server may share kinds this version does not use.
return records.Where(record => SharedSettingKinds.IsKnown(record.Kind)).ToList();
}
/// <summary>
/// Stores a new document. A different document already stored raises
/// <see cref="SharedSettingSaveConflictException"/> with it.
/// </summary>
public async Task<SharedSettingRecord> CreateAsync(string kind, string key, SharedSettingWrite write,
CancellationToken cancellationToken = default)
{
Validate(kind, key, write);
using var response = await _httpClient.PostAsync(Url(Path(kind, key)),
JsonContent.Create(write, options: NestServerHttp.JsonOptions), cancellationToken);
return await ReadSaved(response, kind, key, $"save the shared {kind} settings", cancellationToken);
}
/// <summary>
/// Replaces the document stored at <paramref name="expectedRevision"/>. A newer one raises
/// <see cref="SharedSettingSaveConflictException"/>; one that is gone raises <see cref="SharedSettingMissingException"/>.
/// </summary>
public async Task<SharedSettingRecord> UpdateAsync(string kind, string key, SharedSettingWrite write, int expectedRevision,
CancellationToken cancellationToken = default)
{
Validate(kind, key, write);
using var request = new HttpRequestMessage(HttpMethod.Put, Url(Path(kind, key)))
{
Content = JsonContent.Create(write, options: NestServerHttp.JsonOptions),
};
NestServerHttp.AddIfMatch(request, expectedRevision);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new SharedSettingMissingException(kind, key);
return await ReadSaved(response, kind, key, $"save the shared {kind} settings", cancellationToken);
}
/// <summary>Deletes the document stored at <paramref name="expectedRevision"/>; one already gone is not an error.</summary>
public async Task DeleteAsync(string kind, string key, string savedBy, int expectedRevision,
CancellationToken cancellationToken = default)
{
if (SharedSettingRules.NormalizeKey(kind, key) is null)
throw new ArgumentException($"Invalid {kind} settings key.", nameof(key));
using var request = new HttpRequestMessage(HttpMethod.Delete,
Url(Path(kind, key) + "?savedBy=" + Uri.EscapeDataString(savedBy ?? "")));
NestServerHttp.AddIfMatch(request, expectedRevision);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return;
if (response.StatusCode == HttpStatusCode.Conflict)
throw new SharedSettingSaveConflictException(await ReadRecord(response, cancellationToken));
await NestServerHttp.EnsureSuccessAsync(response, $"delete the shared {kind} settings", cancellationToken);
}
private static void Validate(string kind, string key, SharedSettingWrite write)
{
if (SharedSettingRules.GetError(kind, key, write) is { } error)
throw new ArgumentException(error, nameof(write));
}
private static async Task<SharedSettingRecord> ReadSaved(HttpResponseMessage response, string kind, string key, string action,
CancellationToken cancellationToken)
{
if (response.StatusCode == HttpStatusCode.Conflict)
throw new SharedSettingSaveConflictException(await ReadRecord(response, cancellationToken));
if (response.StatusCode == HttpStatusCode.NotFound)
throw new ServerTooOldException(Feature);
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
var saved = await ReadRecord(response, cancellationToken);
if (saved.Kind != kind || saved.Key != SharedSettingRules.NormalizeKey(kind, key) || saved.Revision < 1)
throw new IOException("The nest server returned different settings than were saved.");
return saved;
}
private static async Task<SharedSettingRecord> ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken) =>
await response.Content.ReadFromJsonAsync<SharedSettingRecord>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned empty settings.");
public void Dispose()
{
if (_ownsClient)
_httpClient.Dispose();
}
}
/// <summary>A shared settings write found a newer or different stored document; nothing was changed.</summary>
public sealed class SharedSettingSaveConflictException : IOException
{
public SharedSettingSaveConflictException(SharedSettingRecord current)
: base(string.IsNullOrEmpty(current.SavedBy)
? $"These settings were changed on another workstation at {current.SavedAt:g}."
: $"These settings were changed by {current.SavedBy} at {current.SavedAt:g}.")
{
Current = current;
}
public SharedSettingRecord Current { get; }
}
/// <summary>A shared settings update found the document deleted; nothing was changed.</summary>
public sealed class SharedSettingMissingException(string kind, string key)
: IOException($"The shared {kind} settings '{key}' were deleted on another workstation.")
{
}
+41
View File
@@ -0,0 +1,41 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// The machine configurations shared through the nest server, in the same JSON as
/// <see cref="LocalJsonProvider"/>'s files. Reads come from the store's last-read copy; saves
/// and deletes go to the server and throw <see cref="IOException"/> when it cannot take them.
/// </summary>
public sealed class SharedMachineProvider(SharedSettingsStore store) : IDataProvider
{
public IReadOnlyList<MachineSummary> GetMachines() =>
store.List(SharedSettingKinds.Machine)
.Select(Read)
.OfType<MachineConfig>()
.Select(machine => new MachineSummary(machine.Id, machine.Name))
.ToList();
public MachineConfig? GetMachine(Guid id) =>
store.Get(SharedSettingKinds.Machine, id.ToString("D")) is { } record ? Read(record) : null;
public void SaveMachine(MachineConfig machine) =>
store.Save(SharedSettingKinds.Machine, machine.Id.ToString("D"), Serialize(machine));
public void DeleteMachine(Guid id) => store.Delete(SharedSettingKinds.Machine, id.ToString("D"));
/// <summary>A machine's shared document, the same JSON its local file holds.</summary>
public static string Serialize(MachineConfig machine) => JsonSerializer.Serialize(machine, LocalJsonProvider.JsonOptions);
private static MachineConfig? Read(SharedSettingRecord record)
{
try
{
return JsonSerializer.Deserialize<MachineConfig>(record.Json, LocalJsonProvider.JsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
+121
View File
@@ -0,0 +1,121 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// One settings document every workstation shares through the nest server
/// (<c>/api/settings</c>): a machine configuration, a post's settings, the clamp rail or the
/// nest defaults. <see cref="Json"/> is the document exactly as its owner serializes it;
/// <see cref="Revision"/> guards every change.
/// </summary>
public sealed class SharedSettingRecord
{
public string Kind { get; set; } = "";
public string Key { get; set; } = "";
public int Revision { get; set; }
public string Json { get; set; } = "";
public DateTime SavedAt { get; set; }
public string SavedBy { get; set; } = "";
}
/// <summary>The body of a shared settings create or update.</summary>
public sealed class SharedSettingWrite
{
public string Json { get; set; } = "";
public string SavedBy { get; set; } = "";
}
/// <summary>The kinds of shared settings and the keys of the single-document kinds.</summary>
public static class SharedSettingKinds
{
/// <summary>A <see cref="MachineConfig"/>, keyed by its id.</summary>
public const string Machine = "machine";
/// <summary>A post processor's settings, keyed by the post's type name.</summary>
public const string Post = "post";
/// <summary>The <see cref="ClampSettings"/> new nests start from, under <see cref="DefaultKey"/>.</summary>
public const string Clamps = "clamps";
/// <summary>The <see cref="NestDefaults"/> new nests start from, under <see cref="DefaultKey"/>.</summary>
public const string NestDefaults = "nest-defaults";
/// <summary>The key of the clamp settings and nest defaults documents.</summary>
public const string DefaultKey = "default";
public static IReadOnlyList<string> All { get; } = [Machine, Post, Clamps, NestDefaults];
public static bool IsKnown(string? kind) => kind is not null && All.Contains(kind);
}
/// <summary>Validation shared by the nest server and its clients.</summary>
public static class SharedSettingRules
{
public const int MaxKeyLength = 200;
public const int MaxSavedByLength = 200;
/// <summary>
/// A settings document's limit in characters: far above the largest post configuration, and
/// small enough that the escaped document fits the server's request limit.
/// </summary>
public const int MaxJsonLength = 512 * 1024;
/// <summary>
/// The key as stored: a machine id in lower-case "D" form, any other key as given. Null when
/// the key is not valid for the kind.
/// </summary>
public static string? NormalizeKey(string? kind, string? key)
{
if (!SharedSettingKinds.IsKnown(kind) || string.IsNullOrEmpty(key) || key.Length > MaxKeyLength)
return null;
if (kind == SharedSettingKinds.Machine)
return Guid.TryParse(key, out var id) && id != Guid.Empty ? id.ToString("D") : null;
if (kind is SharedSettingKinds.Clamps or SharedSettingKinds.NestDefaults)
return key == SharedSettingKinds.DefaultKey ? key : null;
return key.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-' or '+') ? key : null;
}
/// <summary>Why the document cannot be stored under the kind and key, or null when it can.</summary>
public static string? GetError(string? kind, string? key, SharedSettingWrite? write)
{
if (!SharedSettingKinds.IsKnown(kind))
return $"Unknown settings kind '{kind}'.";
var normalized = NormalizeKey(kind, key);
if (normalized is null)
return $"Invalid {kind} settings key.";
if (write is null || string.IsNullOrEmpty(write.Json))
return "The settings document is required.";
if (write.Json.Length > MaxJsonLength)
return "The settings document is too large.";
if ((write.SavedBy ?? "").Length > MaxSavedByLength)
return "savedBy is too long.";
try
{
using var document = JsonDocument.Parse(write.Json);
if (document.RootElement.ValueKind != JsonValueKind.Object)
return "The settings document must be a JSON object.";
}
catch (JsonException)
{
return "The settings document is not valid JSON.";
}
if (kind == SharedSettingKinds.Machine)
{
MachineConfig? machine;
try { machine = JsonSerializer.Deserialize<MachineConfig>(write.Json, LocalJsonProvider.JsonOptions); }
catch (JsonException) { return "The machine configuration is not valid."; }
if (machine is null || machine.Id.ToString("D") != normalized)
return "The machine configuration's id does not match its key.";
}
return null;
}
}
+226
View File
@@ -0,0 +1,226 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// The shared settings as last read from the nest server, for callers that read settings
/// synchronously (nest creation, posting). Every successful read is copied to a cache file;
/// when the server cannot be reached the cached copy is served instead and
/// <see cref="IsOffline"/> is set. Writes always go to the server, so nothing can be changed
/// while it is unreachable. Thread-safe.
/// </summary>
public sealed class SharedSettingsStore : IDisposable
{
private static readonly JsonSerializerOptions CacheOptions = new(NestServerHttp.JsonOptions) { WriteIndented = true };
private readonly object _sync = new();
private readonly RemoteSharedSettings _remote;
private readonly string _cachePath;
private readonly string _savedBy;
private List<SharedSettingRecord>? _records;
/// <param name="cachePath">The file holding this server's last-read copy.</param>
/// <param name="savedBy">Who saves through this store, recorded with every change.</param>
public SharedSettingsStore(RemoteSharedSettings remote, string cachePath, string savedBy)
{
_remote = remote;
_cachePath = cachePath;
_savedBy = savedBy ?? "";
}
/// <summary>The last refresh could not reach the server; reads come from the cached copy, if any.</summary>
public bool IsOffline { get; private set; }
/// <summary>The server predates shared settings: there are none, and none can be saved.</summary>
public bool IsUnsupported { get; private set; }
/// <summary>When the settings being served were read from the server, or null when there are none.</summary>
public DateTime? ReadAt { get; private set; }
/// <summary>Why the last refresh failed, or null after a successful one.</summary>
public Exception? LastError { get; private set; }
/// <summary>When the last refresh was attempted, successful or not.</summary>
public DateTime? RefreshedAt { get; private set; }
/// <summary>
/// Reads every document from the server. Returns false when the server could not be
/// reached; the cached copy (or nothing) is then served.
/// </summary>
public bool Refresh()
{
try
{
var records = Task.Run(() => _remote.ListAsync()).GetAwaiter().GetResult();
lock (_sync)
{
_records = records.ToList();
ReadAt = DateTime.Now;
RefreshedAt = ReadAt;
IsOffline = false;
IsUnsupported = false;
LastError = null;
WriteCache();
}
return true;
}
catch (ServerTooOldException ex)
{
lock (_sync)
{
_records = new List<SharedSettingRecord>();
ReadAt = null;
RefreshedAt = DateTime.Now;
IsOffline = false;
IsUnsupported = true;
LastError = ex;
}
return true;
}
catch (Exception ex) when (IsUnreachable(ex))
{
lock (_sync)
{
if (_records is null)
ReadCache();
RefreshedAt = DateTime.Now;
IsOffline = true;
LastError = ex;
}
return false;
}
}
/// <summary>Every document of <paramref name="kind"/>, by key.</summary>
public IReadOnlyList<SharedSettingRecord> List(string kind)
{
lock (_sync)
return (_records ?? []).Where(record => record.Kind == kind).OrderBy(record => record.Key, StringComparer.Ordinal).ToList();
}
/// <summary>The document of <paramref name="kind"/> under <paramref name="key"/>, or null when none is shared.</summary>
public SharedSettingRecord? Get(string kind, string key)
{
var normalized = SharedSettingRules.NormalizeKey(kind, key);
lock (_sync)
return _records?.FirstOrDefault(record => record.Kind == kind && record.Key == normalized);
}
/// <summary>
/// Stores <paramref name="json"/> on the server, creating the document or replacing the
/// revision last read. Unchanged JSON is not sent. Throws <see cref="SharedSettingSaveConflictException"/>
/// (after taking the newer document) when someone else changed it, and <see cref="IOException"/>
/// when the server cannot be reached.
/// </summary>
public SharedSettingRecord Save(string kind, string key, string json)
{
var current = Get(kind, key);
if (current is not null && current.Json == json)
return current;
var write = new SharedSettingWrite { Json = json, SavedBy = _savedBy };
return Send(kind, key, async () => current is null
? await _remote.CreateAsync(kind, key, write)
: await _remote.UpdateAsync(kind, key, write, current.Revision));
}
/// <summary>Deletes the document, if shared. Throws as <see cref="Save"/> does.</summary>
public void Delete(string kind, string key)
{
if (Get(kind, key) is not { } current)
return;
Send(kind, key, async () =>
{
await _remote.DeleteAsync(kind, key, _savedBy, current.Revision);
return null;
});
}
private SharedSettingRecord Send(string kind, string key, Func<Task<SharedSettingRecord?>> send)
{
SharedSettingRecord? saved;
try
{
saved = Task.Run(send).GetAwaiter().GetResult();
}
catch (SharedSettingSaveConflictException conflict)
{
lock (_sync)
Replace(kind, key, conflict.Current);
throw;
}
catch (SharedSettingMissingException)
{
lock (_sync)
Replace(kind, key, null);
throw;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
throw new IOException(
"The nest server could not be reached, so the shared settings cannot be changed until it is back. " + ex.Message, ex);
}
lock (_sync)
Replace(kind, key, saved);
return saved!;
}
private void Replace(string kind, string key, SharedSettingRecord? record)
{
var normalized = SharedSettingRules.NormalizeKey(kind, key);
_records ??= new List<SharedSettingRecord>();
_records.RemoveAll(existing => existing.Kind == kind && existing.Key == normalized);
if (record is not null)
_records.Add(record);
if (!IsOffline)
WriteCache();
}
private void WriteCache()
{
try
{
var directory = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(_cachePath));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
var temporary = _cachePath + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(new Cache { ReadAt = ReadAt, Records = _records ?? [] }, CacheOptions));
File.Move(temporary, _cachePath, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// The copy only matters while the server is down; the settings themselves are on the server.
}
}
private void ReadCache()
{
try
{
if (!File.Exists(_cachePath))
return;
var cache = JsonSerializer.Deserialize<Cache>(File.ReadAllText(_cachePath), CacheOptions);
if (cache?.Records is null || cache.Records.Any(record => record is null))
return;
_records = cache.Records.Where(record => SharedSettingKinds.IsKnown(record.Kind)).ToList();
ReadAt = cache.ReadAt;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
// An unreadable copy is the same as none.
}
}
// A server error is treated like an unreachable server: the cached copy is still the best available.
private static bool IsUnreachable(Exception ex) =>
ex is HttpRequestException or TaskCanceledException or IOException;
public void Dispose() => _remote.Dispose();
private sealed class Cache
{
public DateTime? ReadAt { get; set; }
public List<SharedSettingRecord> Records { get; set; } = [];
}
}
@@ -22,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(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, 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.
@@ -80,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=13");
Execute(factory.DatabasePath, "PRAGMA user_version=14");
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -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(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, 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(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class MaterialListTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListMaterials());
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
@@ -74,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(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
+1 -1
View File
@@ -76,7 +76,7 @@ public sealed class NestUnitsTests
Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units));
Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units);
Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units);
Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, Units units) => new()
@@ -151,7 +151,7 @@ public sealed class SetReleaseDrawingTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
var kept = upgraded.GetRelease(releaseId)!;
Assert.Empty(kept.Drawings);
Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity));
+1 -1
View File
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(13L, 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));
@@ -0,0 +1,193 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
/// <summary>
/// The settings every workstation shares (<c>/api/settings</c>): revision-guarded documents,
/// the v13 upgrade, the HTTP contract, and the desktop's store with its offline copy.
/// </summary>
public sealed class SharedSettingsTests
{
private const string PostKey = "Synthetic.Posts.Laser";
[Fact]
public void Database_RevisesConflictsAndContinuesRevisionsAfterADelete()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var (created, isNew) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":1}"""));
var (same, sameIsNew) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":1}"""));
var updated = database.UpdateSharedSetting("post", PostKey, Write("""{"feed":2}"""), created.Revision);
var unchanged = database.UpdateSharedSetting("post", PostKey, Write("""{"feed":2}"""), updated!.Revision);
Assert.Equal((1, true, 1, false, 2, 2), (created.Revision, isNew, same.Revision, sameIsNew, updated.Revision, unchanged!.Revision));
var stale = Assert.Throws<SharedSettingConflictException>(() =>
database.UpdateSharedSetting("post", PostKey, Write("""{"feed":3}"""), created.Revision));
Assert.Equal(2, stale.Current.Revision);
Assert.Throws<SharedSettingConflictException>(() => database.CreateSharedSetting("post", PostKey, Write("""{"feed":3}""")));
Assert.True(database.DeleteSharedSetting("post", PostKey, "tester", 2));
Assert.Null(database.GetSharedSetting("post", PostKey));
Assert.Empty(database.ListSharedSettings());
Assert.Null(database.UpdateSharedSetting("post", PostKey, Write("""{"feed":4}"""), 3));
var (recreated, _) = database.CreateSharedSetting("post", PostKey, Write("""{"feed":5}"""));
Assert.Equal(4, recreated.Revision);
Assert.Equal(4L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM shared_setting_revisions"));
Assert.Equal("tester", Assert.Single(database.ListSharedSettings()).SavedBy);
}
[Fact]
public void Database_StoresMachinesUnderTheirLowerCaseId()
{
using var factory = new ServerFactory();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
var machine = new MachineConfig { Name = "Synthetic laser" };
database.CreateSharedSetting("machine", machine.Id.ToString().ToUpperInvariant(), Write(SharedMachineProvider.Serialize(machine)));
Assert.Equal(machine.Id.ToString("D"), Assert.Single(database.ListSharedSettings()).Key);
Assert.NotNull(database.GetSharedSetting("machine", machine.Id.ToString().ToUpperInvariant()));
Assert.Throws<ArgumentException>(() =>
database.CreateSharedSetting("machine", Guid.NewGuid().ToString(), Write(SharedMachineProvider.Serialize(machine))));
}
[Fact]
public void V12Upgrade_StartsWithNoSharedSettings()
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
Execute(factory.DatabasePath,
"DROP TABLE shared_setting_revisions; DROP TABLE shared_settings; PRAGMA user_version=12;");
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListSharedSettings());
}
[Fact]
public async Task Api_FollowsTheCreateAndIfMatchContract()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var url = $"/api/settings/post/{PostKey}";
var created = await client.PostAsJsonAsync(url, Write("""{"feed":1}"""));
var again = await client.PostAsJsonAsync(url, Write("""{"feed":1}"""));
var different = await client.PostAsJsonAsync(url, Write("""{"feed":2}"""));
var unguarded = await client.PutAsJsonAsync(url, Write("""{"feed":2}"""));
var stale = await Put(client, url, Write("""{"feed":2}"""), revision: 5);
var updated = await Put(client, url, Write("""{"feed":2}"""), revision: 1);
var unknownKind = await client.GetAsync($"/api/settings/printer/{PostKey}");
var badKey = await client.PostAsJsonAsync("/api/settings/clamps/other", Write("{}"));
var notObject = await client.PostAsJsonAsync("/api/settings/post/Other.Post", Write("[1]"));
var extraField = await client.PostAsync("/api/settings/post/Other.Post",
new StringContent("""{"json":"{}","savedBy":"","revision":1}""", MediaTypeHeaderValue.Parse("application/json")));
Assert.Equal(
new[] { HttpStatusCode.Created, HttpStatusCode.OK, HttpStatusCode.Conflict, (HttpStatusCode)428,
HttpStatusCode.Conflict, HttpStatusCode.OK, HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest },
new[] { created.StatusCode, again.StatusCode, different.StatusCode, unguarded.StatusCode, stale.StatusCode,
updated.StatusCode, unknownKind.StatusCode, badKey.StatusCode, notObject.StatusCode, extraField.StatusCode });
Assert.Equal(1, (await different.Content.ReadFromJsonAsync<SharedSettingRecord>())!.Revision);
Assert.Equal(2, (await updated.Content.ReadFromJsonAsync<SharedSettingRecord>())!.Revision);
using var delete = new HttpRequestMessage(HttpMethod.Delete, url + "?savedBy=tester");
delete.Headers.IfMatch.Add(new EntityTagHeaderValue("\"2\""));
Assert.Equal(HttpStatusCode.NoContent, (await client.SendAsync(delete)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(url)).StatusCode);
Assert.Empty((await client.GetFromJsonAsync<List<SharedSettingRecord>>("/api/settings"))!);
}
[Fact]
public void Store_SavesThroughTheServerAndTakesTheNewerDocumentOnAConflict()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var cache = Path.Combine(factory.DirectoryPath, "cache", "settings.json");
using var mine = Store(client, cache, "me");
using var theirs = Store(client, Path.Combine(factory.DirectoryPath, "theirs.json"), "them");
var machine = new MachineConfig { Name = "Synthetic laser" };
Assert.True(mine.Refresh());
new SharedMachineProvider(mine).SaveMachine(machine);
mine.Save("post", PostKey, """{"feed":1}""");
Assert.True(theirs.Refresh());
theirs.Save("post", PostKey, """{"feed":2}""");
var conflict = Assert.Throws<SharedSettingSaveConflictException>(() => mine.Save("post", PostKey, """{"feed":3}"""));
Assert.Equal(("them", """{"feed":2}"""), (conflict.Current.SavedBy, mine.Get("post", PostKey)!.Json));
Assert.Equal("""{"feed":3}""", mine.Save("post", PostKey, """{"feed":3}""").Json);
var provider = new SharedMachineProvider(theirs);
Assert.Equal("Synthetic laser", Assert.Single(provider.GetMachines()).Name);
Assert.Equal(machine.Id, provider.GetMachine(machine.Id)!.Id);
provider.DeleteMachine(machine.Id);
Assert.True(mine.Refresh());
Assert.Empty(mine.List("machine"));
Assert.True(File.Exists(cache));
}
[Fact]
public void Store_ServesTheLastCopyReadOnlyWhileTheServerIsUnreachable()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var cache = Path.Combine(factory.DirectoryPath, "settings.json");
using (var online = Store(client, cache, "me"))
{
online.Refresh();
online.Save("post", PostKey, """{"feed":1}""");
}
// Nothing listens on the discard port, so every request fails as an unreachable server does.
using var offline = new SharedSettingsStore(
new RemoteSharedSettings("http://127.0.0.1:9/", TimeSpan.FromSeconds(2)), cache, "me");
Assert.False(offline.Refresh());
Assert.True(offline.IsOffline);
Assert.NotNull(offline.ReadAt);
Assert.Equal("""{"feed":1}""", offline.Get("post", PostKey)!.Json);
Assert.Same(offline.Get("post", PostKey), offline.Save("post", PostKey, """{"feed":1}"""));
var refused = Assert.ThrowsAny<IOException>(() => offline.Save("post", PostKey, """{"feed":2}"""));
Assert.IsNotType<SharedSettingSaveConflictException>(refused);
Assert.Equal("""{"feed":1}""", offline.Get("post", PostKey)!.Json);
}
private static SharedSettingsStore Store(HttpClient client, string cache, string savedBy) =>
new(new RemoteSharedSettings(client, client.BaseAddress!.ToString()), cache, savedBy);
private static SharedSettingWrite Write(string json) => new() { Json = json, SavedBy = "tester" };
private static async Task<HttpResponseMessage> Put(HttpClient client, string url, SharedSettingWrite write, int revision)
{
using var request = new HttpRequestMessage(HttpMethod.Put, url) { Content = JsonContent.Create(write) };
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{revision}\""));
return await client.SendAsync(request);
}
private static void Execute(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
private static object? Scalar(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
return command.ExecuteScalar();
}
}
+192
View File
@@ -0,0 +1,192 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// Settings every workstation shares (<see cref="SharedSettingKinds"/>): <c>shared_settings</c>
/// holds each document's current revision and <c>shared_setting_revisions</c> every revision,
/// including deletions, so a bad change can be traced and recovered by hand. A deleted document
/// keeps its row; creating it again continues its revision count, so a client holding an old
/// revision can never overwrite the new one.
/// </summary>
public sealed partial class NestDatabase
{
private const string SettingColumnDefinitions = """
kind TEXT NOT NULL,
key TEXT NOT NULL,
revision INTEGER NOT NULL,
json TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
savedAt TEXT NOT NULL,
savedBy TEXT NOT NULL DEFAULT ''
""";
// v12 -> v13. The tables start empty: the first workstation offers to upload its own settings.
private const string SharedSettingSchema = $"""
CREATE TABLE IF NOT EXISTS shared_settings ({SettingColumnDefinitions}, PRIMARY KEY (kind, key));
CREATE TABLE IF NOT EXISTS shared_setting_revisions ({SettingColumnDefinitions}, PRIMARY KEY (kind, key, revision));
PRAGMA user_version=13;
""";
private const string SettingColumns = "kind, key, revision, json, deleted, savedAt, savedBy";
/// <summary>Every current, undeleted settings document, by kind then key.</summary>
public IReadOnlyList<SharedSettingRecord> ListSharedSettings()
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {SettingColumns} FROM shared_settings WHERE deleted=0 ORDER BY kind, key";
using var reader = command.ExecuteReader();
var records = new List<SharedSettingRecord>();
while (reader.Read())
records.Add(ReadSetting(reader).Record);
return records;
}
}
/// <summary>The current document, or null when it was never stored or is deleted.</summary>
public SharedSettingRecord? GetSharedSetting(string kind, string key)
{
var stored = FindSetting(kind, key);
return stored is { Deleted: false } ? stored.Value.Record : null;
}
/// <summary>
/// Stores a new document (revision 1, or the next revision of a deleted one). A document
/// already stored is a conflict carrying it, unless it holds the same JSON, which is a no-op.
/// </summary>
public (SharedSettingRecord Record, bool Created) CreateSharedSetting(string kind, string key, SharedSettingWrite write)
{
key = Validate(kind, key, write);
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var stored = FindSetting(kind, key);
if (stored is { Deleted: false } current)
{
if (current.Record.Json == write.Json)
return (current.Record, false);
throw new SharedSettingConflictException(current.Record, "These settings already exist. Reload before saving.");
}
var record = WriteSetting(kind, key, write, (stored?.Record.Revision ?? 0) + 1, deleted: false, transaction);
transaction.Commit();
return (record, true);
}
}
/// <summary>
/// Appends a revision. Returns null when the document is not stored (or deleted); a stale
/// revision is a conflict, and the same JSON is a no-op.
/// </summary>
public SharedSettingRecord? UpdateSharedSetting(string kind, string key, SharedSettingWrite write, int expectedRevision)
{
key = Validate(kind, key, write);
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
if (FindSetting(kind, key) is not { Deleted: false } current)
return null;
if (current.Record.Revision != expectedRevision)
throw new SharedSettingConflictException(current.Record, "These settings were changed by someone else. Reload before saving.");
if (current.Record.Json == write.Json)
return current.Record;
var record = WriteSetting(kind, key, write, checked(current.Record.Revision + 1), deleted: false, transaction);
transaction.Commit();
return record;
}
}
/// <summary>Deletes the document as a new revision. Returns false when it is not stored (or already deleted).</summary>
public bool DeleteSharedSetting(string kind, string key, string savedBy, int expectedRevision)
{
key = SharedSettingRules.NormalizeKey(kind, key) ?? throw new ArgumentException($"Invalid {kind} settings key.", nameof(key));
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
if (FindSetting(kind, key) is not { Deleted: false } current)
return false;
if (current.Record.Revision != expectedRevision)
throw new SharedSettingConflictException(current.Record, "These settings were changed by someone else. Reload before deleting.");
var write = new SharedSettingWrite { Json = current.Record.Json, SavedBy = savedBy ?? "" };
WriteSetting(kind, key, write, checked(current.Record.Revision + 1), deleted: true, transaction);
transaction.Commit();
return true;
}
}
private static string Validate(string kind, string key, SharedSettingWrite write)
{
if (SharedSettingRules.GetError(kind, key, write) is { } error)
throw new ArgumentException(error, nameof(write));
return SharedSettingRules.NormalizeKey(kind, key)!;
}
private (SharedSettingRecord Record, bool Deleted)? FindSetting(string kind, string? key)
{
key = SharedSettingRules.NormalizeKey(kind, key);
if (key is null)
return null;
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {SettingColumns} FROM shared_settings WHERE kind=$kind AND key=$key";
command.Parameters.AddWithValue("$kind", kind);
command.Parameters.AddWithValue("$key", key);
using var reader = command.ExecuteReader();
return reader.Read() ? ReadSetting(reader) : null;
}
}
private SharedSettingRecord WriteSetting(string kind, string key, SharedSettingWrite write, int revision, bool deleted,
SqliteTransaction transaction)
{
var record = new SharedSettingRecord
{
Kind = kind,
Key = key,
Revision = revision,
Json = write.Json,
SavedAt = DateTime.Now,
SavedBy = write.SavedBy ?? "",
};
foreach (var statement in new[] { "INSERT OR REPLACE INTO shared_settings", "INSERT INTO shared_setting_revisions" })
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"{statement} ({SettingColumns}) VALUES ($kind, $key, $revision, $json, $deleted, $savedAt, $savedBy)";
command.Parameters.AddWithValue("$kind", record.Kind);
command.Parameters.AddWithValue("$key", record.Key);
command.Parameters.AddWithValue("$revision", record.Revision);
command.Parameters.AddWithValue("$json", record.Json);
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$savedAt", record.SavedAt.ToString("o", CultureInfo.InvariantCulture));
command.Parameters.AddWithValue("$savedBy", record.SavedBy);
command.ExecuteNonQuery();
}
return record;
}
private static (SharedSettingRecord Record, bool Deleted) ReadSetting(SqliteDataReader reader) => (
new SharedSettingRecord
{
Kind = reader.GetString(0),
Key = reader.GetString(1),
Revision = reader.GetInt32(2),
Json = reader.GetString(3),
SavedAt = DateTime.Parse(reader.GetString(5), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
SavedBy = reader.GetString(6),
},
reader.GetInt64(4) != 0);
}
/// <summary>A guarded settings write found a different current document; nothing was changed.</summary>
public sealed class SharedSettingConflictException(SharedSettingRecord current, string message) : Exception(message)
{
public SharedSettingRecord Current { get; } = current;
}
+7 -1
View File
@@ -342,7 +342,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 12) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 13) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -429,6 +429,12 @@ public sealed partial class NestDatabase : IDisposable
}
if (version < 12)
AddUnits(command);
if (version < 13)
{
// Settings every workstation shares, empty until a workstation uploads its own.
command.CommandText = SharedSettingSchema;
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
+1
View File
@@ -153,6 +153,7 @@ app.MapDrawingLibrary();
app.MapDrawingSets();
app.MapReleases();
app.MapMaterials();
app.MapSettings();
app.Run();
+86
View File
@@ -0,0 +1,86 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// <c>/api/settings</c>: the settings every workstation shares (machines, post settings, clamps
/// and nest defaults), one JSON document per kind and key. Creating returns 201, or 200 when the
/// same document is already stored; changes and deletes follow the If-Match contract (428
/// missing, 400 malformed, 409 stale with the current document).
/// </summary>
internal static class SettingRoutes
{
private static readonly JsonSerializerOptions WriteOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};
public static void MapSettings(this IEndpointRouteBuilder app)
{
app.MapGet("/api/settings", (NestDatabase db) => Results.Ok(db.ListSharedSettings()));
app.MapGet("/api/settings/{kind}/{key}", (string kind, string key, NestDatabase db) =>
!SharedSettingKinds.IsKnown(kind) ? Results.BadRequest($"Unknown settings kind '{kind}'.") :
db.GetSharedSetting(kind, key) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapPost("/api/settings/{kind}/{key}", async (string kind, string key, HttpRequest request, NestDatabase db) =>
{
var (write, error) = await ReadWrite(request, kind, key);
if (error is not null) return error;
try
{
var (record, created) = db.CreateSharedSetting(kind, key, write!);
return created ? Results.Created($"/api/settings/{kind}/{record.Key}", record) : Results.Ok(record);
}
catch (SharedSettingConflictException conflict) { return Conflict(conflict); }
});
app.MapPut("/api/settings/{kind}/{key}", async (string kind, string key, HttpRequest request, NestDatabase db) =>
{
var precondition = IfMatch.Read(request, out var expected);
if (precondition is not null) return precondition;
var (write, error) = await ReadWrite(request, kind, key);
if (error is not null) return error;
try
{
var updated = db.UpdateSharedSetting(kind, key, write!, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (SharedSettingConflictException conflict) { return Conflict(conflict); }
});
app.MapDelete("/api/settings/{kind}/{key}", (string kind, string key, string? savedBy, HttpRequest request, NestDatabase db) =>
{
var precondition = IfMatch.Read(request, out var expected);
if (precondition is not null) return precondition;
if (SharedSettingRules.NormalizeKey(kind, key) is null)
return Results.BadRequest($"Invalid {kind} settings key.");
if ((savedBy ?? "").Length > SharedSettingRules.MaxSavedByLength)
return Results.BadRequest("savedBy is too long.");
try
{
return db.DeleteSharedSetting(kind, key, savedBy ?? "", expected) ? Results.NoContent() : Results.NotFound();
}
catch (SharedSettingConflictException conflict) { return Conflict(conflict); }
});
}
private static IResult Conflict(SharedSettingConflictException conflict) =>
Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
private static async Task<(SharedSettingWrite? Write, IResult? Error)> ReadWrite(HttpRequest request, string kind, string key)
{
var (json, error) = await DrawingSetRoutes.ReadJson(request, "settings document");
if (error is not null) return (null, error);
SharedSettingWrite? write;
try { write = JsonSerializer.Deserialize<SharedSettingWrite>(json!, WriteOptions); }
catch (JsonException) { return (null, Results.BadRequest("Invalid settings JSON.")); }
return SharedSettingRules.GetError(kind, key, write) is { } invalid
? (null, Results.BadRequest(invalid))
: (write, null);
}
}
+133
View File
@@ -0,0 +1,133 @@
using System.Text.Json;
using OpenNest.Data;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
using OpenNest.Posts.CincinnatiCIFiber;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.Data;
/// <summary>
/// The documents every workstation shares through the nest server: their key and document
/// rules, and the JSON forms of clamps, nest defaults and post settings, which must read back
/// exactly what their files hold.
/// </summary>
public class SharedSettingsTests
{
[Theory]
[InlineData("machine", "D3F0B2A4-0D7B-4B8C-9A55-3C8B7B0F1E22", "d3f0b2a4-0d7b-4b8c-9a55-3c8b7b0f1e22")]
[InlineData("machine", "00000000-0000-0000-0000-000000000000", null)]
[InlineData("machine", "CL-980", null)]
[InlineData("post", "OpenNest.Posts.CincinnatiCIFiber.CIFiberPostProcessor", "OpenNest.Posts.CincinnatiCIFiber.CIFiberPostProcessor")]
[InlineData("post", "../escape", null)]
[InlineData("post", "", null)]
[InlineData("clamps", "default", "default")]
[InlineData("clamps", "other", null)]
[InlineData("nest-defaults", "default", "default")]
[InlineData("unknown", "default", null)]
public void NormalizeKey_AcceptsOnlyEachKindsKeys(string kind, string key, string? expected) =>
Assert.Equal(expected, SharedSettingRules.NormalizeKey(kind, key));
[Fact]
public void GetError_RequiresAJsonObjectAndAMatchingMachineId()
{
var machine = new MachineConfig { Name = "Synthetic laser" };
var json = SharedMachineProvider.Serialize(machine);
Assert.Null(SharedSettingRules.GetError("machine", machine.Id.ToString(), Write(json)));
Assert.NotNull(SharedSettingRules.GetError("machine", Guid.NewGuid().ToString(), Write(json)));
Assert.NotNull(SharedSettingRules.GetError("post", "Synthetic.Post", Write("[1, 2]")));
Assert.NotNull(SharedSettingRules.GetError("post", "Synthetic.Post", Write("{ not json")));
Assert.NotNull(SharedSettingRules.GetError("post", "Synthetic.Post", Write("")));
Assert.NotNull(SharedSettingRules.GetError("post", "Synthetic.Post", Write(new string(' ', SharedSettingRules.MaxJsonLength + 1))));
Assert.Null(SharedSettingRules.GetError("post", "Synthetic.Post", Write("{}")));
}
[Fact]
public void ClampSettings_ParseReadsWhatToJsonWrites()
{
var settings = new ClampSettings
{
Enabled = true,
Units = Units.Millimeters,
Width = 38,
Depth = 16,
RemovalMinutes = 0.5,
Centers = [150, 530],
FarEndCenters = [200],
};
var read = ClampSettings.Parse(settings.ToJson(), out var status);
Assert.Equal(ClampSettingsStatus.Ok, status);
Assert.Equal(settings.ToJson(), read.ToJson());
ClampSettings.Parse("{ not json", out var invalid);
Assert.Equal(ClampSettingsStatus.Invalid, invalid);
}
[Fact]
public void NestDefaults_ParseReadsWhatToJsonWrites()
{
var defaults = new NestDefaults
{
Units = Units.Millimeters,
Size = new Size(1500, 3000),
Quadrant = 3,
PartSpacing = 6,
EdgeSpacing = new Spacing(5, 6, 7, 8),
MachineId = Guid.NewGuid(),
};
var read = NestDefaults.Parse(defaults.ToJson(), Units.Inches, out var status);
Assert.Equal(NestDefaultsStatus.Ok, status);
Assert.Equal(defaults.ToJson(), read.ToJson());
Assert.Equal(Units.Inches, NestDefaults.Parse("{ not json", Units.Inches, out var invalid).Units);
Assert.Equal(NestDefaultsStatus.Invalid, invalid);
}
public static TheoryData<IJsonConfigPostProcessor, IJsonConfigPostProcessor, Action<object>> Posts => new()
{
{
new CincinnatiPostProcessor(new CincinnatiPostConfig { ConfigurationName = "Synthetic CL" }),
new CincinnatiPostProcessor(new CincinnatiPostConfig()),
config => Assert.Equal("Synthetic CL", ((CincinnatiPostConfig)config).ConfigurationName)
},
{
new CIFiberPostProcessor(new CIFiberPostConfig { ConfigurationName = "Synthetic Fiber" }),
new CIFiberPostProcessor(new CIFiberPostConfig()),
config => Assert.Equal("Synthetic Fiber", ((CIFiberPostConfig)config).ConfigurationName)
},
{
new GravographISPostProcessor(new GravographISPostConfig { Engrave = new LayerCutConfig { Depth = 0.125 } }),
new GravographISPostProcessor(new GravographISPostConfig()),
config => Assert.Equal(0.125, ((GravographISPostConfig)config).Engrave.Depth)
},
};
[Theory]
[MemberData(nameof(Posts))]
public void PostSettings_LoadConfigReadsWhatSerializeConfigWrites(
IJsonConfigPostProcessor source, IJsonConfigPostProcessor target, Action<object> check)
{
var json = source.SerializeConfig();
target.LoadConfig(json);
check(target.Config);
Assert.Equal(json, target.SerializeConfig());
}
[Theory]
[MemberData(nameof(Posts))]
public void PostSettings_UnusableJsonKeepsTheCurrentSettings(
IJsonConfigPostProcessor source, IJsonConfigPostProcessor _, Action<object> check)
{
Assert.ThrowsAny<JsonException>(() => source.LoadConfig("null"));
Assert.ThrowsAny<JsonException>(() => source.LoadConfig("{ not json"));
check(source.Config);
}
private static SharedSettingWrite Write(string json) => new() { Json = json, SavedBy = "tester" };
}