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

+192
View File
@@ -0,0 +1,192 @@
using System.Globalization;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// Settings every workstation shares (<see cref="SharedSettingKinds"/>): <c>shared_settings</c>
/// holds each document's current revision and <c>shared_setting_revisions</c> every revision,
/// including deletions, so a bad change can be traced and recovered by hand. A deleted document
/// keeps its row; creating it again continues its revision count, so a client holding an old
/// revision can never overwrite the new one.
/// </summary>
public sealed partial class NestDatabase
{
private const string SettingColumnDefinitions = """
kind TEXT NOT NULL,
key TEXT NOT NULL,
revision INTEGER NOT NULL,
json TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
savedAt TEXT NOT NULL,
savedBy TEXT NOT NULL DEFAULT ''
""";
// v12 -> v13. The tables start empty: the first workstation offers to upload its own settings.
private const string SharedSettingSchema = $"""
CREATE TABLE IF NOT EXISTS shared_settings ({SettingColumnDefinitions}, PRIMARY KEY (kind, key));
CREATE TABLE IF NOT EXISTS shared_setting_revisions ({SettingColumnDefinitions}, PRIMARY KEY (kind, key, revision));
PRAGMA user_version=13;
""";
private const string SettingColumns = "kind, key, revision, json, deleted, savedAt, savedBy";
/// <summary>Every current, undeleted settings document, by kind then key.</summary>
public IReadOnlyList<SharedSettingRecord> ListSharedSettings()
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {SettingColumns} FROM shared_settings WHERE deleted=0 ORDER BY kind, key";
using var reader = command.ExecuteReader();
var records = new List<SharedSettingRecord>();
while (reader.Read())
records.Add(ReadSetting(reader).Record);
return records;
}
}
/// <summary>The current document, or null when it was never stored or is deleted.</summary>
public SharedSettingRecord? GetSharedSetting(string kind, string key)
{
var stored = FindSetting(kind, key);
return stored is { Deleted: false } ? stored.Value.Record : null;
}
/// <summary>
/// Stores a new document (revision 1, or the next revision of a deleted one). A document
/// already stored is a conflict carrying it, unless it holds the same JSON, which is a no-op.
/// </summary>
public (SharedSettingRecord Record, bool Created) CreateSharedSetting(string kind, string key, SharedSettingWrite write)
{
key = Validate(kind, key, write);
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var stored = FindSetting(kind, key);
if (stored is { Deleted: false } current)
{
if (current.Record.Json == write.Json)
return (current.Record, false);
throw new SharedSettingConflictException(current.Record, "These settings already exist. Reload before saving.");
}
var record = WriteSetting(kind, key, write, (stored?.Record.Revision ?? 0) + 1, deleted: false, transaction);
transaction.Commit();
return (record, true);
}
}
/// <summary>
/// Appends a revision. Returns null when the document is not stored (or deleted); a stale
/// revision is a conflict, and the same JSON is a no-op.
/// </summary>
public SharedSettingRecord? UpdateSharedSetting(string kind, string key, SharedSettingWrite write, int expectedRevision)
{
key = Validate(kind, key, write);
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
if (FindSetting(kind, key) is not { Deleted: false } current)
return null;
if (current.Record.Revision != expectedRevision)
throw new SharedSettingConflictException(current.Record, "These settings were changed by someone else. Reload before saving.");
if (current.Record.Json == write.Json)
return current.Record;
var record = WriteSetting(kind, key, write, checked(current.Record.Revision + 1), deleted: false, transaction);
transaction.Commit();
return record;
}
}
/// <summary>Deletes the document as a new revision. Returns false when it is not stored (or already deleted).</summary>
public bool DeleteSharedSetting(string kind, string key, string savedBy, int expectedRevision)
{
key = SharedSettingRules.NormalizeKey(kind, key) ?? throw new ArgumentException($"Invalid {kind} settings key.", nameof(key));
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
if (FindSetting(kind, key) is not { Deleted: false } current)
return false;
if (current.Record.Revision != expectedRevision)
throw new SharedSettingConflictException(current.Record, "These settings were changed by someone else. Reload before deleting.");
var write = new SharedSettingWrite { Json = current.Record.Json, SavedBy = savedBy ?? "" };
WriteSetting(kind, key, write, checked(current.Record.Revision + 1), deleted: true, transaction);
transaction.Commit();
return true;
}
}
private static string Validate(string kind, string key, SharedSettingWrite write)
{
if (SharedSettingRules.GetError(kind, key, write) is { } error)
throw new ArgumentException(error, nameof(write));
return SharedSettingRules.NormalizeKey(kind, key)!;
}
private (SharedSettingRecord Record, bool Deleted)? FindSetting(string kind, string? key)
{
key = SharedSettingRules.NormalizeKey(kind, key);
if (key is null)
return null;
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {SettingColumns} FROM shared_settings WHERE kind=$kind AND key=$key";
command.Parameters.AddWithValue("$kind", kind);
command.Parameters.AddWithValue("$key", key);
using var reader = command.ExecuteReader();
return reader.Read() ? ReadSetting(reader) : null;
}
}
private SharedSettingRecord WriteSetting(string kind, string key, SharedSettingWrite write, int revision, bool deleted,
SqliteTransaction transaction)
{
var record = new SharedSettingRecord
{
Kind = kind,
Key = key,
Revision = revision,
Json = write.Json,
SavedAt = DateTime.Now,
SavedBy = write.SavedBy ?? "",
};
foreach (var statement in new[] { "INSERT OR REPLACE INTO shared_settings", "INSERT INTO shared_setting_revisions" })
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"{statement} ({SettingColumns}) VALUES ($kind, $key, $revision, $json, $deleted, $savedAt, $savedBy)";
command.Parameters.AddWithValue("$kind", record.Kind);
command.Parameters.AddWithValue("$key", record.Key);
command.Parameters.AddWithValue("$revision", record.Revision);
command.Parameters.AddWithValue("$json", record.Json);
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$savedAt", record.SavedAt.ToString("o", CultureInfo.InvariantCulture));
command.Parameters.AddWithValue("$savedBy", record.SavedBy);
command.ExecuteNonQuery();
}
return record;
}
private static (SharedSettingRecord Record, bool Deleted) ReadSetting(SqliteDataReader reader) => (
new SharedSettingRecord
{
Kind = reader.GetString(0),
Key = reader.GetString(1),
Revision = reader.GetInt32(2),
Json = reader.GetString(3),
SavedAt = DateTime.Parse(reader.GetString(5), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
SavedBy = reader.GetString(6),
},
reader.GetInt64(4) != 0);
}
/// <summary>A guarded settings write found a different current document; nothing was changed.</summary>
public sealed class SharedSettingConflictException(SharedSettingRecord current, string message) : Exception(message)
{
public SharedSettingRecord Current { get; } = current;
}
+7 -1
View File
@@ -342,7 +342,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 12) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 13) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -429,6 +429,12 @@ public sealed partial class NestDatabase : IDisposable
}
if (version < 12)
AddUnits(command);
if (version < 13)
{
// Settings every workstation shares, empty until a workstation uploads its own.
command.CommandText = SharedSettingSchema;
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
+1
View File
@@ -153,6 +153,7 @@ app.MapDrawingLibrary();
app.MapDrawingSets();
app.MapReleases();
app.MapMaterials();
app.MapSettings();
app.Run();
+86
View File
@@ -0,0 +1,86 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// <c>/api/settings</c>: the settings every workstation shares (machines, post settings, clamps
/// and nest defaults), one JSON document per kind and key. Creating returns 201, or 200 when the
/// same document is already stored; changes and deletes follow the If-Match contract (428
/// missing, 400 malformed, 409 stale with the current document).
/// </summary>
internal static class SettingRoutes
{
private static readonly JsonSerializerOptions WriteOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};
public static void MapSettings(this IEndpointRouteBuilder app)
{
app.MapGet("/api/settings", (NestDatabase db) => Results.Ok(db.ListSharedSettings()));
app.MapGet("/api/settings/{kind}/{key}", (string kind, string key, NestDatabase db) =>
!SharedSettingKinds.IsKnown(kind) ? Results.BadRequest($"Unknown settings kind '{kind}'.") :
db.GetSharedSetting(kind, key) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapPost("/api/settings/{kind}/{key}", async (string kind, string key, HttpRequest request, NestDatabase db) =>
{
var (write, error) = await ReadWrite(request, kind, key);
if (error is not null) return error;
try
{
var (record, created) = db.CreateSharedSetting(kind, key, write!);
return created ? Results.Created($"/api/settings/{kind}/{record.Key}", record) : Results.Ok(record);
}
catch (SharedSettingConflictException conflict) { return Conflict(conflict); }
});
app.MapPut("/api/settings/{kind}/{key}", async (string kind, string key, HttpRequest request, NestDatabase db) =>
{
var precondition = IfMatch.Read(request, out var expected);
if (precondition is not null) return precondition;
var (write, error) = await ReadWrite(request, kind, key);
if (error is not null) return error;
try
{
var updated = db.UpdateSharedSetting(kind, key, write!, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (SharedSettingConflictException conflict) { return Conflict(conflict); }
});
app.MapDelete("/api/settings/{kind}/{key}", (string kind, string key, string? savedBy, HttpRequest request, NestDatabase db) =>
{
var precondition = IfMatch.Read(request, out var expected);
if (precondition is not null) return precondition;
if (SharedSettingRules.NormalizeKey(kind, key) is null)
return Results.BadRequest($"Invalid {kind} settings key.");
if ((savedBy ?? "").Length > SharedSettingRules.MaxSavedByLength)
return Results.BadRequest("savedBy is too long.");
try
{
return db.DeleteSharedSetting(kind, key, savedBy ?? "", expected) ? Results.NoContent() : Results.NotFound();
}
catch (SharedSettingConflictException conflict) { return Conflict(conflict); }
});
}
private static IResult Conflict(SharedSettingConflictException conflict) =>
Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
private static async Task<(SharedSettingWrite? Write, IResult? Error)> ReadWrite(HttpRequest request, string kind, string key)
{
var (json, error) = await DrawingSetRoutes.ReadJson(request, "settings document");
if (error is not null) return (null, error);
SharedSettingWrite? write;
try { write = JsonSerializer.Deserialize<SharedSettingWrite>(json!, WriteOptions); }
catch (JsonException) { return (null, Results.BadRequest("Invalid settings JSON.")); }
return SharedSettingRules.GetError(kind, key, write) is { } invalid
? (null, Results.BadRequest(invalid))
: (write, null);
}
}