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; } = [];
}
}