feat(server): preserve file-save nest revisions

This commit is contained in:
aj committed 2026-10-11 16:37:28 -04:00
1 parent 3d512c794c
commit a47b414392
6 files changed
+430 -83

No files matched your search

+3
View File
@@ -11,6 +11,9 @@ public sealed class NestRecord
{
public Guid Id { get; set; }
/// <summary>Server revision; zero means the server does not support history.</summary>
public int Revision { get; set; }
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
+15 -35
View File
@@ -99,6 +99,7 @@ public sealed class NestApiTests
nest.Status = NestStatus.ToBeCut;
nest.Plates[0].Parts[0].Location = new Vector(4, 5);
var archive = Archive(nest);
server.Client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
var updated = await session.SaveAsync(server.Repository, server.Url, nest, archive);
Assert.Equal(created.Id, updated.Id);
@@ -133,41 +134,17 @@ public sealed class NestApiTests
}
[Fact]
public async Task MetadataUpdate_ChangesFieldsWithoutTouchingArchiveOrFileSize()
public async Task MetadataAndDelete_AreBlockedWithoutMutation()
{
using var server = new TestServerSession();
var nest = SyntheticNest();
var archive = Archive(nest);
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, archive);
var change = Clone(created);
change.Name = "Renamed by metadata update";
change.Status = NestStatus.HasBeenCut;
change.Comments = "Metadata-only change";
change.FileSize = 1; // Server-computed; must be ignored.
var updated = await server.Repository.UpdateMetadataAsync(created.Id, change);
Assert.Equal(created.Id, updated.Id);
Assert.Equal("Renamed by metadata update", updated.Name);
Assert.Equal(NestStatus.HasBeenCut, updated.Status);
Assert.Equal("Metadata-only change", updated.Comments);
Assert.Equal(archive.LongLength, updated.FileSize);
AssertSameMetadata(updated, (await server.Repository.GetMetadataAsync(created.Id))!);
Assert.Equal(archive, await server.Repository.GetFileAsync(created.Id));
}
[Fact]
public async Task Delete_RemovesRecordAndArchive()
{
using var server = new TestServerSession();
var nest = SyntheticNest();
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, Archive(nest));
await server.Repository.DeleteAsync(created.Id);
Assert.Empty(await server.Repository.ListAsync());
Assert.Null(await server.Repository.GetMetadataAsync(created.Id));
Assert.Null(await server.Repository.GetFileAsync(created.Id));
var created = await server.SeedAsync();
var before = await server.SnapshotAsync();
server.Client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
using var metadata = await server.Client.PutAsync($"/api/nests/{created.Id}/metadata", new StringContent("{}"));
Assert.Equal(HttpStatusCode.NotImplemented, metadata.StatusCode);
using var delete = await server.Client.DeleteAsync($"/api/nests/{created.Id}");
Assert.Equal(HttpStatusCode.NotImplemented, delete.StatusCode);
Assert.Equal(before, await server.SnapshotAsync());
}
public static TheoryData<string, string> InvalidUploads()
@@ -199,6 +176,7 @@ public sealed class NestApiTests
var method = route == "create" ? HttpMethod.Post : HttpMethod.Put;
var path = route == "create" ? "/api/nests" : $"/api/nests/{existing.Id}/file";
using var request = new HttpRequestMessage(method, path) { Content = InvalidUploadContent(body) };
request.Headers.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
using var response = await server.Client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -208,7 +186,7 @@ public sealed class NestApiTests
[Theory]
[InlineData("{not-json")]
[InlineData("null")]
public async Task MetadataUpdate_WithInvalidJson_Returns400AndLeavesStoreUnchanged(string json)
public async Task MetadataUpdate_WithoutPrecondition_Returns428AndLeavesStoreUnchanged(string json)
{
using var server = new TestServerSession();
var existing = await server.SeedAsync();
@@ -217,7 +195,7 @@ public sealed class NestApiTests
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await server.Client.PutAsync($"/api/nests/{existing.Id}/metadata", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal((HttpStatusCode)428, response.StatusCode);
Assert.Equal(before, await server.SnapshotAsync());
}
@@ -250,6 +228,7 @@ public sealed class NestApiTests
foreach (var create in requests)
{
using var request = create();
request.Headers.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
using var response = await server.Client.SendAsync(request);
Assert.True(response.StatusCode == HttpStatusCode.NotFound,
$"{request.Method} {request.RequestUri} returned {(int)response.StatusCode}, expected 404.");
@@ -339,6 +318,7 @@ public sealed class NestApiTests
{
var expected = NestRecordFactory.FromNest(nest, saved.Id, archive.LongLength);
expected.SavedAt = saved.SavedAt;
expected.Revision = saved.Revision;
return expected;
}
@@ -17,8 +17,8 @@ public sealed class NestDatabaseConcurrencyTests
[InlineData("GetFile")]
[InlineData("Insert")]
[InlineData("UpdateFile")]
[InlineData("UpdateMetadata")]
[InlineData("Delete")]
[InlineData("History")]
[InlineData("GetRevisionFile")]
[InlineData("Health")]
[InlineData("Dispose")]
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
@@ -44,9 +44,9 @@ public sealed class NestDatabaseConcurrencyTests
case "Get": database.Get(id); break;
case "GetFile": database.GetFile(id); break;
case "Insert": database.Insert(Guid.NewGuid(), record, new byte[] { 4 }); break;
case "UpdateFile": database.Update(id, record, new byte[] { 5 }); break;
case "UpdateMetadata": database.Update(id, record, null); break;
case "Delete": database.Delete(id); break;
case "UpdateFile": database.Update(id, record, new byte[] { 5 }, 1); break;
case "History": database.History(id, 0, 100); break;
case "GetRevisionFile": database.GetRevisionFile(id, 1); break;
case "Health": Assert.True(database.IsHealthy()); break;
case "Dispose": database.Dispose(); break;
default: throw new InvalidOperationException($"Unknown test operation: {operation}");
@@ -102,10 +102,10 @@ public sealed class NestDatabaseConcurrencyTests
() => database.Query(new NestQuery { Search = "Synthetic", Limit = 1 }),
() => database.Get(id),
() => database.GetFile(id),
() => database.Update(id, record, new byte[] { 2 }),
() => database.Update(id, record, null),
() => database.Update(id, record, new byte[] { 2 }, 1),
() => database.History(id, 0, 100),
() => Assert.True(database.IsHealthy()),
() => database.Delete(id),
() => database.GetRevisionFile(id, 1),
];
foreach (var operation in operations)
{
@@ -143,17 +143,13 @@ public sealed class NestDatabaseConcurrencyTests
Assert.Equal(file, database.GetFile(id));
record.Comments = "Archive updated";
file = file.Concat(new byte[] { 42 }).ToArray();
saved = database.Update(id, record, file)!;
saved = database.Update(id, record, file, 1)!;
AssertStoredRecord(record, id, file, saved);
record.Comments = "Metadata only";
saved = database.Update(id, record, null)!;
saved = database.Update(id, record, file, saved.Revision)!;
AssertStoredRecord(record, id, file, saved);
Assert.Equal(file.LongLength, saved.FileSize);
Assert.Equal(file, database.GetFile(id));
var transient = Guid.NewGuid();
database.Insert(transient, record, new byte[] { 9 });
Assert.True(database.Delete(transient));
Assert.Null(database.Get(transient));
records.Add((saved, file));
}
return records;
@@ -193,11 +189,12 @@ public sealed class NestDatabaseConcurrencyTests
Assert.Contains(await repository.ListAsync(watchdog.Token), r => r.Id == saved.Id);
nest.Notes = $"Archive change {lane}-{index}";
file = NestApiTests.Archive(nest);
client.DefaultRequestHeaders.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"1\""));
var updated = await session.SaveAsync(repository, client.BaseAddress.ToString(), nest, file,
cancellationToken: watchdog.Token);
client.DefaultRequestHeaders.IfMatch.Clear();
AssertStoredRecord(NestRecordFactory.FromNest(nest, saved.Id, file.LongLength), saved.Id, file, updated);
updated.Comments = $"Metadata only {lane}-{index}";
saved = await repository.UpdateMetadataAsync(updated.Id, updated, watchdog.Token);
saved = updated;
AssertStoredRecord(updated, updated.Id, file, saved);
Assert.Equal(file.LongLength, saved.FileSize);
Assert.Equal(file, await repository.GetFileAsync(saved.Id, watchdog.Token));
@@ -243,6 +240,7 @@ public sealed class NestDatabaseConcurrencyTests
expected.Id = id;
expected.FileSize = file.LongLength;
expected.SavedAt = actual.SavedAt;
expected.Revision = actual.Revision;
Assert.NotEqual(default, actual.SavedAt);
AssertRecord(expected, actual);
}
+247
View File
@@ -0,0 +1,247 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using OpenNest.Data;
namespace OpenNest.Server.Tests;
public sealed class NestRevisionTests
{
private const string LegacySchema = """
CREATE TABLE nests (
id TEXT PRIMARY KEY, name TEXT NOT NULL, customer TEXT NOT NULL DEFAULT '',
dateCreated TEXT NOT NULL, dateModified TEXT NOT NULL, material TEXT NOT NULL DEFAULT '',
thickness REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'Quote',
plateCount INTEGER NOT NULL DEFAULT 0, partCount INTEGER NOT NULL DEFAULT 0,
comments TEXT NOT NULL DEFAULT '', madeBy TEXT NOT NULL DEFAULT '',
fileSize INTEGER NOT NULL DEFAULT 0, savedAt TEXT NOT NULL, file BLOB NOT NULL);
""";
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } };
internal static MultipartFormDataContent Body(NestRecord record, byte[] bytes)
{
var body = new MultipartFormDataContent();
body.Add(new StringContent(JsonSerializer.Serialize(record)), "metadata");
body.Add(new ByteArrayContent(bytes), "file", "fixture.nest");
return body;
}
[Fact]
public async Task ChangedSave_PreservesExactHistoryAndNoOp()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
var record = new NestRecord { Name = "Initial" };
using var created = await client.PostAsync("/api/nests", Body(record, [1, 2]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
Assert.Equal(1, current.Revision);
record.Name = "Changed";
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
using var updated = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4]));
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
var latest = (await updated.Content.ReadFromJsonAsync<NestRecord>(Options))!;
Assert.Equal(2, latest.Revision);
Assert.Equal(new byte[] { 1, 2 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file"));
Assert.Equal(new byte[] { 3, 4 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
client.DefaultRequestHeaders.Remove("If-Match");
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"2\"");
using var retry = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4]));
Assert.Equal(latest.SavedAt, (await retry.Content.ReadFromJsonAsync<NestRecord>(Options))!.SavedAt);
var page = await client.GetFromJsonAsync<NestPage>($"/api/nests/{current.Id}/revisions?limit=1", Options);
Assert.Equal(2, page!.Total);
Assert.Equal(2, Assert.Single(page.Items).Revision);
}
[Theory]
[InlineData(null, 428)]
[InlineData("1", 400)]
[InlineData("\"0\"", 400)]
[InlineData("\"2\"", 409)]
public async Task RejectedWrite_LeavesCurrentAndHistoryUnchanged(string? header, int status)
{
using var factory = new ServerFactory();
Guid id;
using (var client = factory.CreateClient())
{
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
id = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!.Id;
if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header);
using var response = await client.PutAsync($"/api/nests/{id}/file", Body(new NestRecord { Name = "Rejected" }, [2]));
Assert.Equal(status, (int)response.StatusCode);
Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file"));
}
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal("Initial", reopened.Get(id)!.Name);
Assert.Equal(1, reopened.Get(id)!.Revision);
}
[Fact]
public async Task FailedRevisionInsert_RollsBackCurrentProjection()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False"))
{
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TRIGGER fail_revision BEFORE INSERT ON nest_revisions BEGIN SELECT RAISE(ABORT, 'fixture failure'); END";
command.ExecuteNonQuery();
}
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
using var failed = await client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = "Rejected" }, [2]));
Assert.Equal(HttpStatusCode.InternalServerError, failed.StatusCode);
Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal("Initial", reopened.Get(current.Id)!.Name);
Assert.Equal(1, reopened.History(current.Id, 0, 100)!.Total);
}
[Fact]
public void OldSchema_BackfillsExactRowsAndIsIdempotent()
{
using var factory = new ServerFactory();
var ids = new[] { Guid.NewGuid(), Guid.NewGuid() };
var bytes = new[] { new byte[] { 0, 1, 255 }, new byte[] { 7, 8, 9, 0 } };
var timestamp = new DateTime(2026, 1, 2, 3, 4, 5);
using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False"))
{
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = LegacySchema;
command.ExecuteNonQuery();
for (var i = 0; i < ids.Length; i++)
{
command.Parameters.Clear();
command.CommandText = "INSERT INTO nests VALUES ($id, $name, 'Fixture', $date, $date, 'Alloy', 0.25, 'ToBeCut', 2, 3, 'Notes', 'Author', $size, $date, $file)";
command.Parameters.AddWithValue("$id", ids[i].ToString());
command.Parameters.AddWithValue("$name", $"Legacy {i}");
command.Parameters.AddWithValue("$date", timestamp.ToString("o"));
command.Parameters.AddWithValue("$size", bytes[i].Length);
command.Parameters.AddWithValue("$file", bytes[i]);
command.ExecuteNonQuery();
}
}
for (var open = 0; open < 2; open++)
{
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(2, database.List().Count);
for (var i = 0; i < ids.Length; i++)
{
var current = database.Get(ids[i])!;
Assert.Equal(1, current.Revision);
Assert.Equal(timestamp, current.SavedAt);
Assert.Equal($"Legacy {i}", current.Name);
Assert.Equal("Fixture", current.Customer);
Assert.Equal("Notes", current.Comments);
Assert.Equal(bytes[i], database.GetFile(ids[i]));
Assert.Equal(bytes[i], database.GetRevisionFile(ids[i], 1));
var historical = Assert.Single(database.History(ids[i], 0, 100)!.Items);
Assert.Equal(JsonSerializer.Serialize(current), JsonSerializer.Serialize(historical));
}
}
}
[Theory]
[InlineData("limit=0")]
[InlineData("limit=201")]
[InlineData("offset=-1")]
[InlineData("limit=1&limit=2")]
[InlineData("limit=no")]
[InlineData("unknown=1")]
public async Task History_InvalidPagination_IsRejected(string query)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/revisions?{query}");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task ConcurrentGuardedSaves_OneWinsAndOneConflicts()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
var responses = await Task.WhenAll(Enumerable.Range(2, 2).Select(i =>
client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = $"Writer {i}" }, [(byte)i]))));
try
{
Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.OK));
var conflict = Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.Conflict));
Assert.Equal(2, (await conflict.Content.ReadFromJsonAsync<NestRecord>(Options))!.Revision);
var history = await client.GetFromJsonAsync<NestPage>($"/api/nests/{current.Id}/revisions", Options);
Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision));
Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file"));
}
finally { foreach (var response in responses) response.Dispose(); }
}
[Theory]
[InlineData("metadata", null, 428)]
[InlineData("delete", null, 428)]
[InlineData("metadata", "\"1\"", 501)]
[InlineData("delete", "\"1\"", 501)]
[InlineData("file", "\"1\"", 400)]
public async Task BlockedOrMalformedWrite_PreservesAllSnapshots(string route, string? header, int status)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header);
using var response = route == "delete"
? await client.DeleteAsync($"/api/nests/{current.Id}")
: await client.PutAsync($"/api/nests/{current.Id}/{route}", new StringContent("Malformed"));
Assert.Equal(status, (int)response.StatusCode);
Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
var history = await client.GetFromJsonAsync<NestPage>($"/api/nests/{current.Id}/revisions", Options);
Assert.Equal(1, history!.Total);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal("Initial", reopened.Get(current.Id)!.Name);
Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(current.Id, 1));
}
[Fact]
public async Task UnknownHistoryOrVersion_Is404_AndOffsetIsBounded()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var unknown = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/revisions");
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
var current = (await created.Content.ReadFromJsonAsync<NestRecord>(Options))!;
using var missing = await client.GetAsync($"/api/nests/{current.Id}/revisions/2/file");
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
var page = await client.GetFromJsonAsync<NestPage>($"/api/nests/{current.Id}/revisions?offset=2147483647", Options);
Assert.Empty(page!.Items);
Assert.Equal(1, page.Total);
}
[Fact]
public void FailedMigration_RollsBackSchemaAndCanRestart()
{
using var factory = new ServerFactory();
using var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = LegacySchema + "CREATE TABLE nest_revisions (fixture TEXT);";
command.ExecuteNonQuery();
Assert.Throws<SqliteException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
command.CommandText = "PRAGMA user_version";
Assert.Equal(0L, command.ExecuteScalar());
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('nests') WHERE name='revision'";
Assert.Equal(0L, command.ExecuteScalar());
command.CommandText = "DROP TABLE nest_revisions";
command.ExecuteNonQuery();
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Empty(database.List());
}
}
+95 -10
View File
@@ -29,7 +29,9 @@ public sealed class NestDatabase : IDisposable
Pooling = pooling,
}.ToString());
_connection.Open();
Execute("""
try
{
Execute("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS nests (
id TEXT PRIMARY KEY,
@@ -50,6 +52,9 @@ public sealed class NestDatabase : IDisposable
);
CREATE INDEX IF NOT EXISTS ix_nests_savedAt ON nests(savedAt DESC);
""");
Migrate();
}
catch { _connection.Dispose(); throw; }
}
public IReadOnlyList<NestRecord> List()
@@ -134,7 +139,9 @@ public sealed class NestDatabase : IDisposable
{
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO nests (id, name, customer, dateCreated, dateModified, material,
thickness, status, plateCount, partCount, comments, madeBy, fileSize,
@@ -145,42 +152,114 @@ public sealed class NestDatabase : IDisposable
""";
AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery();
Snapshot(id, transaction);
transaction.Commit();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
}
}
public NestRecord? Update(Guid id, NestRecord record, byte[]? file)
public NestRecord? Update(Guid id, NestRecord record, byte[] file, int expectedRevision)
{
lock (_sync)
{
if (Get(id) is null)
return null;
using var transaction = _connection.BeginTransaction();
var current = Get(id);
if (current is null) return null;
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
if (SameMetadata(current, record) && GetFile(id)!.AsSpan().SequenceEqual(file)) return current;
using var command = _connection.CreateCommand();
var fileClause = file is null ? "" : ", file = $file, fileSize = $fileSize";
command.Transaction = transaction;
const string fileClause = ", file = $file, fileSize = $fileSize";
command.CommandText = $"""
UPDATE nests SET
name = $name, customer = $customer, dateCreated = $dateCreated,
dateModified = $dateModified, material = $material,
thickness = $thickness, status = $status, plateCount = $plateCount,
partCount = $partCount, comments = $comments, madeBy = $madeBy,
savedAt = $savedAt{fileClause}
savedAt = $savedAt, revision = $revision{fileClause}
WHERE id = $id
""";
AddRecordParameters(command, id, record, file, updateFile: file is not null);
command.Parameters.AddWithValue("$revision", checked(current.Revision + 1));
command.ExecuteNonQuery();
Snapshot(id, transaction);
transaction.Commit();
return Get(id);
}
}
public bool Delete(Guid id)
public NestPage? History(Guid id, int offset, int limit)
{
if (offset < 0 || limit is < 1 or > 200) throw new ArgumentOutOfRangeException(nameof(limit));
lock (_sync)
{
if (Get(id) is null) return null;
using var count = _connection.CreateCommand();
count.CommandText = "SELECT COUNT(*) FROM nest_revisions WHERE id=$id";
count.Parameters.AddWithValue("$id", id.ToString());
var total = checked((int)(long)count.ExecuteScalar()!);
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$limit", limit);
command.Parameters.AddWithValue("$offset", offset);
var records = new List<NestRecord>();
using var reader = command.ExecuteReader();
while (reader.Read()) records.Add(ReadRecord(reader));
return new NestPage { Items = records, Total = total, Offset = offset, Limit = limit };
}
}
public byte[]? GetRevisionFile(Guid id, int revision)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = "DELETE FROM nests WHERE id = $id";
command.CommandText = "SELECT file FROM nest_revisions WHERE id=$id AND revision=$revision";
command.Parameters.AddWithValue("$id", id.ToString());
return command.ExecuteNonQuery() > 0;
command.Parameters.AddWithValue("$revision", revision);
return command.ExecuteScalar() as byte[];
}
}
private static bool SameMetadata(NestRecord a, NestRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated &&
a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness &&
a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount &&
a.Comments == b.Comments && a.MadeBy == b.MadeBy;
private void Snapshot(Guid id, SqliteTransaction transaction)
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"INSERT INTO nest_revisions ({RecordColumns}, file) SELECT {RecordColumns}, file FROM nests WHERE id=$id";
command.Parameters.AddWithValue("$id", id.ToString());
command.ExecuteNonQuery();
}
private void Migrate()
{
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 1) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
ALTER TABLE nests ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;
CREATE TABLE nest_revisions AS SELECT * FROM nests WHERE 0;
CREATE UNIQUE INDEX ix_nest_revisions_key ON nest_revisions(id, revision);
INSERT INTO nest_revisions SELECT * FROM nests;
PRAGMA user_version=1;
""";
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
@@ -217,7 +296,7 @@ public sealed class NestDatabase : IDisposable
private const string RecordColumns = """
id, name, customer, dateCreated, dateModified, material, thickness,
status, plateCount, partCount, comments, madeBy, fileSize, savedAt
status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision
""";
// Case-insensitive (ASCII) substring over the text columns and the status's stored
@@ -303,6 +382,12 @@ public sealed class NestDatabase : IDisposable
MadeBy = reader.GetString(11),
FileSize = reader.GetInt64(12),
SavedAt = DateTime.Parse(reader.GetString(13)),
Revision = reader.GetInt32(14),
};
}
}
public sealed class RevisionConflictException(NestRecord current) : Exception("The nest has changed. Reload or Save As.")
{
public NestRecord Current { get; } = current;
}
+56 -22
View File
@@ -62,41 +62,75 @@ app.MapPost("/api/nests", async (HttpContext http, NestDatabase db) =>
var id = record!.Id == Guid.Empty ? Guid.NewGuid() : record.Id;
return Results.Ok(db.Insert(id, record, file!));
});
app.MapGet("/api/nests/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
{
var offset = 0;
var limit = 100;
foreach (var parameter in request.Query)
{
if (parameter.Value.Count != 1 || !int.TryParse(parameter.Value[0], out var value))
return Results.BadRequest("Invalid history pagination.");
switch (parameter.Key)
{
case "offset": offset = value; break;
case "limit": limit = value; break;
default: return Results.BadRequest("Unknown history parameter.");
}
}
if (offset < 0 || limit is < 1 or > 200) return Results.BadRequest("Invalid history pagination.");
return db.History(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound();
});
app.MapGet("/api/nests/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetRevisionFile(id, revision) is { } file ? Results.File(file, "application/zip", $"{id}-r{revision}.nest") : Results.NotFound());
app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) =>
{
var precondition = ReadPrecondition(http.Request, out var expected);
if (precondition is not null) return precondition;
var (record, file, error) = await ReadMultipart(http);
if (error is not null)
return error;
var updated = db.Update(id, record!, file);
return updated is null ? Results.NotFound() : Results.Ok(updated);
});
app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpContext http, NestDatabase db) =>
{
NestRecord? record;
if (error is not null) return error;
try
{
record = await http.Request.ReadFromJsonAsync<NestRecord>();
var updated = db.Update(id, record!, file!, expected);
return updated is null ? Results.NotFound() : Results.Ok(updated);
}
catch (JsonException)
catch (RevisionConflictException conflict)
{
return Results.BadRequest("Invalid metadata JSON.");
return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
}
if (record is null)
return Results.BadRequest("Missing metadata.");
// Keep the stored archive's size; metadata updates never touch the file.
var updated = db.Update(id, record, file: null);
return updated is null ? Results.NotFound() : Results.Ok(updated);
});
app.MapDelete("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
db.Delete(id) ? Results.NoContent() : Results.NotFound());
app.MapPut("/api/nests/{id:guid}/metadata", (Guid id, HttpRequest request, NestDatabase db) =>
BlockDeferredMutation(id, request, db));
app.MapDelete("/api/nests/{id:guid}", (Guid id, HttpRequest request, NestDatabase db) =>
BlockDeferredMutation(id, request, db));
app.Run();
static IResult? ReadPrecondition(HttpRequest request, out int revision)
{
revision = 0;
var values = request.Headers.IfMatch;
if (values.Count == 0) return Results.Json("Upgrade/reload required: send If-Match with the current revision.", statusCode: 428);
var text = values.Count == 1 ? values[0] : null;
if (text is null || text.Length < 3 || text[0] != '"' || text[^1] != '"' ||
!text[1..^1].All(c => c is >= '0' and <= '9') ||
!int.TryParse(text[1..^1], out revision) || revision < 1)
return Results.BadRequest("If-Match must contain one quoted positive integer revision.");
return null;
}
static IResult BlockDeferredMutation(Guid id, HttpRequest request, NestDatabase db)
{
var error = ReadPrecondition(request, out var expected);
if (error is not null) return error;
var current = db.Get(id);
if (current is null) return Results.NotFound();
if (current.Revision != expected) return Results.Json(current, statusCode: 409);
return Results.Json("Versioned metadata and visibility changes are not available yet; no changes were made.", statusCode: 501);
}
// Shared multipart reader for upload endpoints. Returns a (record, file, error) triple.
static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultipart(HttpContext http)
{