mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-11 20:21:09 -05:00
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:
1 parent
9f3c22dc4f
commit
90e2fd2e6e
20 files changed
+1266
-60
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ app.MapDrawingLibrary();
|
||||
app.MapDrawingSets();
|
||||
app.MapReleases();
|
||||
app.MapMaterials();
|
||||
app.MapSettings();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user