diff --git a/OpenNest.Data/ClampSettings.cs b/OpenNest.Data/ClampSettings.cs
index b666081b..9d57219b 100644
--- a/OpenNest.Data/ClampSettings.cs
+++ b/OpenNest.Data/ClampSettings.cs
@@ -194,19 +194,39 @@ public sealed class ClampSettings
///
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);
+ }
+
+ ///
+ /// Reads settings from (a file or the shared copy) the way
+ /// reads a file, never throwing.
+ ///
+ 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 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
///
public void Save(string path, int maxRetries = 3)
{
- var dto = new Dictionary
- {
- ["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
}
}
+ /// The settings as writes them, for the shared copy.
+ public string ToJson()
+ {
+ var dto = new Dictionary
+ {
+ ["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
diff --git a/OpenNest.Data/LocalJsonProvider.cs b/OpenNest.Data/LocalJsonProvider.cs
index fe13ac0b..11dac4a1 100644
--- a/OpenNest.Data/LocalJsonProvider.cs
+++ b/OpenNest.Data/LocalJsonProvider.cs
@@ -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,
diff --git a/OpenNest.Data/NestDefaults.cs b/OpenNest.Data/NestDefaults.cs
index 16a3d404..5967f3d5 100644
--- a/OpenNest.Data/NestDefaults.cs
+++ b/OpenNest.Data/NestDefaults.cs
@@ -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(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);
+ }
+
+ ///
+ /// Reads defaults from (a file or the shared copy) the way
+ /// reads a file, never throwing.
+ ///
+ public static NestDefaults Parse(string json, Units fallbackUnits, out NestDefaultsStatus status)
+ {
+ var defaults = WithUnits(Fallback, fallbackUnits);
+
+ NestDefaultsDto? dto;
+ try
+ {
+ dto = JsonSerializer.Deserialize(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;
+ }
+
///
/// Captures the current units and plate defaults from a nest.
///
@@ -237,24 +261,7 @@ public sealed class NestDefaults
///
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
}
}
+ /// The defaults as writes them, for the shared copy.
+ 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);
+ }
+
///
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse
/// would also accept numeric strings ("7") and comma-joined names,
diff --git a/OpenNest.Data/RemoteSharedSettings.cs b/OpenNest.Data/RemoteSharedSettings.cs
new file mode 100644
index 00000000..177d0ba2
--- /dev/null
+++ b/OpenNest.Data/RemoteSharedSettings.cs
@@ -0,0 +1,151 @@
+using System.Net;
+using System.Net.Http.Json;
+
+namespace OpenNest.Data;
+
+///
+/// Talks to the settings every workstation shares on the nest server (/api/settings).
+/// A server without them raises ; a stale write raises
+/// ; other failures surface as .
+///
+public sealed class RemoteSharedSettings : IDisposable
+{
+ private const string Feature = "shared machine settings";
+
+ private readonly HttpClient _httpClient;
+ private readonly bool _ownsClient;
+ private readonly Uri _baseUri;
+
+ /// A client that gives up after , so an unreachable server falls back quickly.
+ public RemoteSharedSettings(string baseUrl, TimeSpan timeout)
+ : this(new HttpClient { Timeout = timeout }, baseUrl)
+ {
+ _ownsClient = true;
+ }
+
+ /// For tests and hosts that pool HttpClient instances.
+ 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)}";
+
+ /// Every shared settings document.
+ public async Task> 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>(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();
+ }
+
+ ///
+ /// Stores a new document. A different document already stored raises
+ /// with it.
+ ///
+ public async Task 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);
+ }
+
+ ///
+ /// Replaces the document stored at . A newer one raises
+ /// ; one that is gone raises .
+ ///
+ public async Task 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);
+ }
+
+ /// Deletes the document stored at ; one already gone is not an error.
+ 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 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 ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken) =>
+ await response.Content.ReadFromJsonAsync(NestServerHttp.JsonOptions, cancellationToken)
+ ?? throw new IOException("Nest server returned empty settings.");
+
+ public void Dispose()
+ {
+ if (_ownsClient)
+ _httpClient.Dispose();
+ }
+}
+
+/// A shared settings write found a newer or different stored document; nothing was changed.
+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; }
+}
+
+/// A shared settings update found the document deleted; nothing was changed.
+public sealed class SharedSettingMissingException(string kind, string key)
+ : IOException($"The shared {kind} settings '{key}' were deleted on another workstation.")
+{
+}
diff --git a/OpenNest.Data/SharedMachineProvider.cs b/OpenNest.Data/SharedMachineProvider.cs
new file mode 100644
index 00000000..7549b511
--- /dev/null
+++ b/OpenNest.Data/SharedMachineProvider.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+///
+/// The machine configurations shared through the nest server, in the same JSON as
+/// 's files. Reads come from the store's last-read copy; saves
+/// and deletes go to the server and throw when it cannot take them.
+///
+public sealed class SharedMachineProvider(SharedSettingsStore store) : IDataProvider
+{
+ public IReadOnlyList GetMachines() =>
+ store.List(SharedSettingKinds.Machine)
+ .Select(Read)
+ .OfType()
+ .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"));
+
+ /// A machine's shared document, the same JSON its local file holds.
+ public static string Serialize(MachineConfig machine) => JsonSerializer.Serialize(machine, LocalJsonProvider.JsonOptions);
+
+ private static MachineConfig? Read(SharedSettingRecord record)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(record.Json, LocalJsonProvider.JsonOptions);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/OpenNest.Data/SharedSettings.cs b/OpenNest.Data/SharedSettings.cs
new file mode 100644
index 00000000..fa0e9f5f
--- /dev/null
+++ b/OpenNest.Data/SharedSettings.cs
@@ -0,0 +1,121 @@
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+///
+/// One settings document every workstation shares through the nest server
+/// (/api/settings): a machine configuration, a post's settings, the clamp rail or the
+/// nest defaults. is the document exactly as its owner serializes it;
+/// guards every change.
+///
+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; } = "";
+}
+
+/// The body of a shared settings create or update.
+public sealed class SharedSettingWrite
+{
+ public string Json { get; set; } = "";
+
+ public string SavedBy { get; set; } = "";
+}
+
+/// The kinds of shared settings and the keys of the single-document kinds.
+public static class SharedSettingKinds
+{
+ /// A , keyed by its id.
+ public const string Machine = "machine";
+
+ /// A post processor's settings, keyed by the post's type name.
+ public const string Post = "post";
+
+ /// The new nests start from, under .
+ public const string Clamps = "clamps";
+
+ /// The new nests start from, under .
+ public const string NestDefaults = "nest-defaults";
+
+ /// The key of the clamp settings and nest defaults documents.
+ public const string DefaultKey = "default";
+
+ public static IReadOnlyList All { get; } = [Machine, Post, Clamps, NestDefaults];
+
+ public static bool IsKnown(string? kind) => kind is not null && All.Contains(kind);
+}
+
+/// Validation shared by the nest server and its clients.
+public static class SharedSettingRules
+{
+ public const int MaxKeyLength = 200;
+
+ public const int MaxSavedByLength = 200;
+
+ ///
+ /// 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.
+ ///
+ public const int MaxJsonLength = 512 * 1024;
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Why the document cannot be stored under the kind and key, or null when it can.
+ 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(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;
+ }
+}
diff --git a/OpenNest.Data/SharedSettingsStore.cs b/OpenNest.Data/SharedSettingsStore.cs
new file mode 100644
index 00000000..22ccb53b
--- /dev/null
+++ b/OpenNest.Data/SharedSettingsStore.cs
@@ -0,0 +1,226 @@
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+///
+/// 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
+/// is set. Writes always go to the server, so nothing can be changed
+/// while it is unreachable. Thread-safe.
+///
+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? _records;
+
+ /// The file holding this server's last-read copy.
+ /// Who saves through this store, recorded with every change.
+ public SharedSettingsStore(RemoteSharedSettings remote, string cachePath, string savedBy)
+ {
+ _remote = remote;
+ _cachePath = cachePath;
+ _savedBy = savedBy ?? "";
+ }
+
+ /// The last refresh could not reach the server; reads come from the cached copy, if any.
+ public bool IsOffline { get; private set; }
+
+ /// The server predates shared settings: there are none, and none can be saved.
+ public bool IsUnsupported { get; private set; }
+
+ /// When the settings being served were read from the server, or null when there are none.
+ public DateTime? ReadAt { get; private set; }
+
+ /// Why the last refresh failed, or null after a successful one.
+ public Exception? LastError { get; private set; }
+
+ /// When the last refresh was attempted, successful or not.
+ public DateTime? RefreshedAt { get; private set; }
+
+ ///
+ /// Reads every document from the server. Returns false when the server could not be
+ /// reached; the cached copy (or nothing) is then served.
+ ///
+ 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();
+ 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;
+ }
+ }
+
+ /// Every document of , by key.
+ public IReadOnlyList List(string kind)
+ {
+ lock (_sync)
+ return (_records ?? []).Where(record => record.Kind == kind).OrderBy(record => record.Key, StringComparer.Ordinal).ToList();
+ }
+
+ /// The document of under , or null when none is shared.
+ 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);
+ }
+
+ ///
+ /// Stores on the server, creating the document or replacing the
+ /// revision last read. Unchanged JSON is not sent. Throws
+ /// (after taking the newer document) when someone else changed it, and
+ /// when the server cannot be reached.
+ ///
+ 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));
+ }
+
+ /// Deletes the document, if shared. Throws as does.
+ 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> 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();
+ _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(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 Records { get; set; } = [];
+ }
+}
diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
index 6585b4b7..99f32db8 100644
--- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
+++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
@@ -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(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
index 86b0ffb7..e7aecf67 100644
--- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
+++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
@@ -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]
diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs
index 9aa24cbf..988446e8 100644
--- a/OpenNest.Server.Tests/MaterialListTests.cs
+++ b/OpenNest.Server.Tests/MaterialListTests.cs
@@ -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);
diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs
index f51502bb..34b30a89 100644
--- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs
+++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs
@@ -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()
diff --git a/OpenNest.Server.Tests/NestUnitsTests.cs b/OpenNest.Server.Tests/NestUnitsTests.cs
index b2262637..cf53256a 100644
--- a/OpenNest.Server.Tests/NestUnitsTests.cs
+++ b/OpenNest.Server.Tests/NestUnitsTests.cs
@@ -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()
diff --git a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
index 26c25774..94f513d2 100644
--- a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
+++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
@@ -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));
diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs
index fd8e4493..a25d4dc7 100644
--- a/OpenNest.Server.Tests/SetReleaseTests.cs
+++ b/OpenNest.Server.Tests/SetReleaseTests.cs
@@ -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));
diff --git a/OpenNest.Server.Tests/SharedSettingsTests.cs b/OpenNest.Server.Tests/SharedSettingsTests.cs
new file mode 100644
index 00000000..1df18522
--- /dev/null
+++ b/OpenNest.Server.Tests/SharedSettingsTests.cs
@@ -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;
+
+///
+/// The settings every workstation shares (/api/settings): revision-guarded documents,
+/// the v13 upgrade, the HTTP contract, and the desktop's store with its offline copy.
+///
+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(() =>
+ database.UpdateSharedSetting("post", PostKey, Write("""{"feed":3}"""), created.Revision));
+ Assert.Equal(2, stale.Current.Revision);
+ Assert.Throws(() => 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(() =>
+ 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())!.Revision);
+ Assert.Equal(2, (await updated.Content.ReadFromJsonAsync())!.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>("/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(() => 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(() => offline.Save("post", PostKey, """{"feed":2}"""));
+ Assert.IsNotType(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 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();
+ }
+}
diff --git a/OpenNest.Server/NestDatabase.Settings.cs b/OpenNest.Server/NestDatabase.Settings.cs
new file mode 100644
index 00000000..581965e7
--- /dev/null
+++ b/OpenNest.Server/NestDatabase.Settings.cs
@@ -0,0 +1,192 @@
+using System.Globalization;
+using Microsoft.Data.Sqlite;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// Settings every workstation shares (): shared_settings
+/// holds each document's current revision and shared_setting_revisions 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.
+///
+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";
+
+ /// Every current, undeleted settings document, by kind then key.
+ public IReadOnlyList 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();
+ while (reader.Read())
+ records.Add(ReadSetting(reader).Record);
+ return records;
+ }
+ }
+
+ /// The current document, or null when it was never stored or is deleted.
+ public SharedSettingRecord? GetSharedSetting(string kind, string key)
+ {
+ var stored = FindSetting(kind, key);
+ return stored is { Deleted: false } ? stored.Value.Record : null;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+
+ /// Deletes the document as a new revision. Returns false when it is not stored (or already deleted).
+ 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);
+}
+
+/// A guarded settings write found a different current document; nothing was changed.
+public sealed class SharedSettingConflictException(SharedSettingRecord current, string message) : Exception(message)
+{
+ public SharedSettingRecord Current { get; } = current;
+}
diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs
index f1c1e242..e27a12e3 100644
--- a/OpenNest.Server/NestDatabase.cs
+++ b/OpenNest.Server/NestDatabase.cs
@@ -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();
}
}
diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs
index 4c211ff3..79b9e123 100644
--- a/OpenNest.Server/Program.cs
+++ b/OpenNest.Server/Program.cs
@@ -153,6 +153,7 @@ app.MapDrawingLibrary();
app.MapDrawingSets();
app.MapReleases();
app.MapMaterials();
+app.MapSettings();
app.Run();
diff --git a/OpenNest.Server/SettingRoutes.cs b/OpenNest.Server/SettingRoutes.cs
new file mode 100644
index 00000000..c28a15d9
--- /dev/null
+++ b/OpenNest.Server/SettingRoutes.cs
@@ -0,0 +1,86 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using OpenNest.Data;
+
+namespace OpenNest.Server;
+
+///
+/// /api/settings: 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).
+///
+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(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);
+ }
+}
diff --git a/OpenNest.Tests/Data/SharedSettingsTests.cs b/OpenNest.Tests/Data/SharedSettingsTests.cs
new file mode 100644
index 00000000..23cc1c99
--- /dev/null
+++ b/OpenNest.Tests/Data/SharedSettingsTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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> 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