mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-13 01:21:55 -04:00
feat(server): preserve file-save nest revisions
This commit is contained in:
1 parent
3d512c794c
commit
a47b414392
6 files changed
+430
-83
No files matched your search
@@ -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; } = "";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user