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