diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs index faaadb5..f207cca 100644 --- a/OpenNest.Data/NestRecord.cs +++ b/OpenNest.Data/NestRecord.cs @@ -11,6 +11,9 @@ public sealed class NestRecord { public Guid Id { get; set; } + /// Server revision; zero means the server does not support history. + public int Revision { get; set; } + public string Name { get; set; } = ""; public string Customer { get; set; } = ""; diff --git a/OpenNest.Server.Tests/NestApiTests.cs b/OpenNest.Server.Tests/NestApiTests.cs index 8ab4364..4182ca8 100644 --- a/OpenNest.Server.Tests/NestApiTests.cs +++ b/OpenNest.Server.Tests/NestApiTests.cs @@ -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 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; } diff --git a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs index 32d5e9c..1e4c2da 100644 --- a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs +++ b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs @@ -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); } diff --git a/OpenNest.Server.Tests/NestRevisionTests.cs b/OpenNest.Server.Tests/NestRevisionTests.cs new file mode 100644 index 0000000..4ab392e --- /dev/null +++ b/OpenNest.Server.Tests/NestRevisionTests.cs @@ -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(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(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(Options))!.SavedAt); + var page = await client.GetFromJsonAsync($"/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(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(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(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(Options))!.Revision); + var history = await client.GetFromJsonAsync($"/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(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($"/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(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($"/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(() => 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()); + } + +} diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 1aaad3f..10c9740 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -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 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(); + 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; +} diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 22e5f45..74b2d55 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -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(); + 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) {