diff --git a/OpenNest.Data/INestRepository.cs b/OpenNest.Data/INestRepository.cs index 8cdfafe..d52cdef 100644 --- a/OpenNest.Data/INestRepository.cs +++ b/OpenNest.Data/INestRepository.cs @@ -68,6 +68,10 @@ public interface INestRepository Task DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException("This repository does not support guarded deletion."); + Task RestoreAsync(Guid id, int sourceRevision, int expectedRevision, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support guarded restoration."); + Task RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => throw new NotSupportedException("This repository does not support guarded recovery."); diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs index c6ca828..dbb3477 100644 --- a/OpenNest.Data/NestRecord.cs +++ b/OpenNest.Data/NestRecord.cs @@ -16,6 +16,9 @@ public sealed class NestRecord public bool Deleted { get; set; } + /// Selected historical revision promoted by an explicit Restore. + public int? RestoredFromRevision { get; set; } + public string Name { get; set; } = ""; public string Customer { get; set; } = ""; diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs index 7b9d4a1..16c676b 100644 --- a/OpenNest.Data/RemoteNestRepository.cs +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -236,6 +236,21 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable return await ReadRecord(response, cancellationToken); } + public async Task RestoreAsync(Guid id, int sourceRevision, int expectedRevision, + CancellationToken cancellationToken = default) + { + ValidateId(id); + ValidateRevision(sourceRevision); + ValidateRevision(expectedRevision); + await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Post, + Url($"api/nests/{id}/revisions/{sourceRevision.ToString(CultureInfo.InvariantCulture)}/restore")); + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\"")); + using var response = await _httpClient.SendAsync(request, cancellationToken); + await EnsureSuccess(response, $"restore nest {id}; reload after a conflict", cancellationToken); + return await ReadRecord(response, cancellationToken); + } + public Task DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => ChangeVisibilityAsync(id, expectedRevision, true, cancellationToken); diff --git a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs index 0d12598..eb3aad5 100644 --- a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs +++ b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs @@ -19,6 +19,7 @@ public sealed class NestDatabaseConcurrencyTests [InlineData("UpdateFile")] [InlineData("UpdateMetadata")] [InlineData("SetDeleted")] + [InlineData("Restore")] [InlineData("History")] [InlineData("GetRevisionFile")] [InlineData("Health")] @@ -49,6 +50,7 @@ public sealed class NestDatabaseConcurrencyTests case "UpdateFile": database.Update(id, record, new byte[] { 5 }, 1); break; case "UpdateMetadata": database.UpdateMetadata(id, record, 1); break; case "SetDeleted": database.SetDeleted(id, true, 1); break; + case "Restore": database.Restore(id, 1, 1); break; case "History": database.History(id, 0, 100); break; case "GetRevisionFile": database.GetRevisionFile(id, 1); break; case "Health": Assert.True(database.IsHealthy()); break; @@ -118,6 +120,7 @@ public sealed class NestDatabaseConcurrencyTests () => database.History(id, 0, 100, true), () => database.GetRevisionFile(id, 1, true), () => database.SetDeleted(id, false, 3), + () => database.Restore(id, 1, 4), ]; foreach (var operation in operations) { diff --git a/OpenNest.Server.Tests/NestMutationTests.cs b/OpenNest.Server.Tests/NestMutationTests.cs index 14d0769..60500ed 100644 --- a/OpenNest.Server.Tests/NestMutationTests.cs +++ b/OpenNest.Server.Tests/NestMutationTests.cs @@ -141,6 +141,141 @@ public sealed class NestMutationTests Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover)); } + [Fact] + public async Task Restore_AppendsSelectedSnapshotWithoutChangingInterveningHistory() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var first = await repository.UploadAsync([0, 255], new NestRecord { Name = "Original", Comments = "Before" }); + var second = await repository.UpdateFileAsync(first.Id, [3, 4], new NestRecord { Name = "Changed" }, 1); + var before = await repository.GetRevisionsAsync(first.Id); + var restored = await repository.RestoreAsync(first.Id, 1, 2); + Assert.Equal(1, restored.RestoredFromRevision); + var current = (await repository.GetMetadataAsync(first.Id))!; + Assert.Equal(3, current.Revision); + Assert.Equal(first.Id, current.Id); + Assert.Equal("Original", current.Name); + Assert.Equal("Before", current.Comments); + Assert.Equal(new byte[] { 0, 255 }, await repository.GetFileAsync(first.Id)); + Assert.Equal(new byte[] { 3, 4 }, await repository.GetRevisionFileAsync(first.Id, 2)); + var after = await repository.GetRevisionsAsync(first.Id); + Assert.Equal(3, after.Total); + Assert.Equal(System.Text.Json.JsonSerializer.Serialize(before.Items), System.Text.Json.JsonSerializer.Serialize(after.Items.Skip(1))); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(3, reopened.History(first.Id, 0, 100)!.Total); + Assert.Equal(new byte[] { 0, 255 }, reopened.GetFile(first.Id)); + } + + [Theory] + [InlineData(null, 1, 428)] + [InlineData("1", 1, 400)] + [InlineData("\"2\"", 1, 409)] + [InlineData("\"1\"", 0, 400)] + [InlineData("\"1\"", 99, 404)] + public async Task Restore_RejectedWriteLeavesHistory(string? header, int source, int status) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/nests/{saved.Id}/revisions/{source}/restore"); + if (header is not null) request.Headers.TryAddWithoutValidation("If-Match", header); + using var response = await client.SendAsync(request); + Assert.Equal(status, (int)response.StatusCode); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total); + Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id)); + Assert.Null(reopened.Get(saved.Id)!.RestoredFromRevision); + } + + [Fact] + public async Task Restore_FailedSnapshotRollsBackAndSameContentStillAppends() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var restored = await repository.RestoreAsync(saved.Id, 1, 1); + Assert.Equal(2, restored.Revision); + Assert.Equal(1, restored.RestoredFromRevision); + FailSnapshots(factory.DatabasePath); + Assert.Contains("500", (await Assert.ThrowsAsync(() => repository.RestoreAsync(saved.Id, 1, 2))).Message); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(2, reopened.History(saved.Id, 0, 100)!.Total); + Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id)); + Assert.Equal(1, reopened.Get(saved.Id)!.RestoredFromRevision); + } + + [Theory] + [InlineData("metadata")] + [InlineData("delete")] + [InlineData("restore")] + [InlineData("recover")] + public async Task Mutations_UnknownOrWrongVisibilityIs404(string operation) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + if (operation != "recover") await repository.DeleteAsync(saved.Id, 1); + foreach (var id in new[] { saved.Id, Guid.NewGuid() }) + { + using var request = new HttpRequestMessage(operation == "metadata" ? HttpMethod.Put : + operation == "delete" ? HttpMethod.Delete : HttpMethod.Post, + operation switch + { + "metadata" => $"/api/nests/{id}/metadata", + "delete" => $"/api/nests/{id}", + "restore" => $"/api/nests/{id}/revisions/1/restore", + _ => $"/api/nests/deleted/{id}/recover" + }); + request.Headers.TryAddWithoutValidation("If-Match", "\"1\""); + if (operation == "metadata") request.Content = JsonContent.Create(new NestRecord()); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(operation == "recover" ? 1 : 2, reopened.History(saved.Id, 0, 100, operation != "recover")!.Total); + } + + [Theory] + [InlineData("metadata")] + [InlineData("delete")] + [InlineData("restore")] + [InlineData("recover")] + public async Task ConcurrentMutation_OnlyOneExpectedRevisionWins(string operation) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + var expected = 1; + if (operation == "recover") { await repository.DeleteAsync(saved.Id, 1); expected = 2; } + var attempts = Enumerable.Range(0, 2).Select(async i => + { + using var request = new HttpRequestMessage(operation == "metadata" ? HttpMethod.Put : + operation == "delete" ? HttpMethod.Delete : HttpMethod.Post, + operation switch + { + "metadata" => $"/api/nests/{saved.Id}/metadata", + "delete" => $"/api/nests/{saved.Id}", + "restore" => $"/api/nests/{saved.Id}/revisions/1/restore", + _ => $"/api/nests/deleted/{saved.Id}/recover" + }); + request.Headers.TryAddWithoutValidation("If-Match", $"\"{expected}\""); + if (operation == "metadata") request.Content = JsonContent.Create(new NestRecord { Name = $"Writer {i}" }); + using var response = await client.SendAsync(request); + return response.StatusCode; + }); + var statuses = await Task.WhenAll(attempts); + Assert.Single(statuses.Where(s => s == HttpStatusCode.OK)); + Assert.Single(statuses.Where(s => s == (operation is "delete" or "recover" ? HttpStatusCode.NotFound : HttpStatusCode.Conflict))); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(expected + 1, reopened.History(saved.Id, 0, 100, operation == "delete")!.Total); + Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(saved.Id, 1, operation == "delete")); + } + private static void FailSnapshots(string path) { using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index d9f8658..22d116c 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -164,11 +164,11 @@ public sealed class NestDatabase : IDisposable { lock (_sync) { - return Update(id, record, GetFile(id) ?? [], expectedRevision); + return Update(id, record, null, expectedRevision); } } - public NestRecord? Update(Guid id, NestRecord record, byte[] file, int expectedRevision) + public NestRecord? Update(Guid id, NestRecord record, byte[]? file, int expectedRevision) { lock (_sync) { @@ -176,24 +176,60 @@ public sealed class NestDatabase : IDisposable var current = Get(id); if (current is null) return null; if (current.Revision != expectedRevision) throw new RevisionConflictException(current); + file ??= GetFile(id)!; if (SameMetadata(current, record) && GetFile(id)!.AsSpan().SequenceEqual(file)) return current; - using var command = _connection.CreateCommand(); - command.Transaction = transaction; - const string fileClause = ", file = $file, fileSize = $fileSize"; - command.CommandText = $""" + WriteRevision(id, record, file, current.Revision, null, transaction); + transaction.Commit(); + return Get(id); + } + } + + // Called only under _sync with the caller's transaction; promotion and snapshot are one write. + private void WriteRevision(Guid id, NestRecord record, byte[] file, int currentRevision, + int? sourceRevision, SqliteTransaction transaction) + { + using var command = _connection.CreateCommand(); + 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, revision = $revision{fileClause} + savedAt = $savedAt, revision = $revision, restoredFromRevision=$source{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); + AddRecordParameters(command, id, record, file, updateFile: file is not null); + command.Parameters.AddWithValue("$revision", checked(currentRevision + 1)); + command.Parameters.AddWithValue("$source", (object?)sourceRevision ?? DBNull.Value); + command.ExecuteNonQuery(); + Snapshot(id, transaction); + } + + public NestRecord? Restore(Guid id, int sourceRevision, int expectedRevision) + { + if (sourceRevision < 1) throw new ArgumentOutOfRangeException(nameof(sourceRevision)); + lock (_sync) + { + using var transaction = _connection.BeginTransaction(); + var current = Get(id); + if (current is null) return null; + if (current.Revision != expectedRevision) throw new RevisionConflictException(current); + NestRecord historical; + using (var command = _connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id AND revision=$revision"; + command.Parameters.AddWithValue("$id", id.ToString()); + command.Parameters.AddWithValue("$revision", sourceRevision); + using var reader = command.ExecuteReader(); + if (!reader.Read()) return null; + historical = ReadRecord(reader); + } + // Restore is a deliberate new active revision, including when the bytes already match. + WriteRevision(id, historical, GetRevisionFile(id, sourceRevision)!, current.Revision, sourceRevision, transaction); transaction.Commit(); return Get(id); } @@ -209,7 +245,7 @@ public sealed class NestDatabase : IDisposable if (current.Revision != expectedRevision) throw new RevisionConflictException(current); using var command = _connection.CreateCommand(); command.Transaction = transaction; - command.CommandText = "UPDATE nests SET deleted=$deleted, revision=$revision, savedAt=$savedAt WHERE id=$id"; + command.CommandText = "UPDATE nests SET deleted=$deleted, revision=$revision, savedAt=$savedAt, restoredFromRevision=NULL WHERE id=$id"; command.Parameters.AddWithValue("$id", id.ToString()); command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0); command.Parameters.AddWithValue("$revision", checked(current.Revision + 1)); @@ -280,7 +316,7 @@ public sealed class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 2) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 3) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -301,6 +337,15 @@ public sealed class NestDatabase : IDisposable """; command.ExecuteNonQuery(); } + if (version < 3) + { + command.CommandText = """ + ALTER TABLE nests ADD COLUMN restoredFromRevision INTEGER; + ALTER TABLE nest_revisions ADD COLUMN restoredFromRevision INTEGER; + PRAGMA user_version=3; + """; + command.ExecuteNonQuery(); + } transaction.Commit(); } } @@ -338,7 +383,7 @@ public sealed class NestDatabase : IDisposable private const string RecordColumns = """ id, name, customer, dateCreated, dateModified, material, thickness, - status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted + status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted, restoredFromRevision """; // Case-insensitive (ASCII) substring over the text columns and the status's stored @@ -426,6 +471,7 @@ public sealed class NestDatabase : IDisposable SavedAt = DateTime.Parse(reader.GetString(13)), Revision = reader.GetInt32(14), Deleted = reader.GetInt32(15) != 0, + RestoredFromRevision = reader.IsDBNull(16) ? null : reader.GetInt32(16), }; } } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index b3f83c9..b78629e 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -118,6 +118,19 @@ app.MapGet("/api/nests/deleted/{id:guid}/revisions/{revision:int}/file", (Guid i revision < 1 ? Results.BadRequest("Invalid revision.") : db.GetRevisionFile(id, revision, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound()); +app.MapPost("/api/nests/{id:guid}/revisions/{revision:int}/restore", (Guid id, int revision, HttpRequest request, NestDatabase db) => +{ + var error = ReadPrecondition(request, out var expected); + if (error is not null) return error; + if (revision < 1) return Results.BadRequest("Invalid revision."); + try + { + var restored = db.Restore(id, revision, expected); + return restored is null ? Results.NotFound() : Results.Ok(restored); + } + catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); } +}); + app.Run(); static IResult? ReadPrecondition(HttpRequest request, out int revision) diff --git a/OpenNest.Tests/Data/RemoteNestHistoryTests.cs b/OpenNest.Tests/Data/RemoteNestHistoryTests.cs index 3fb0632..d868fd4 100644 --- a/OpenNest.Tests/Data/RemoteNestHistoryTests.cs +++ b/OpenNest.Tests/Data/RemoteNestHistoryTests.cs @@ -139,10 +139,16 @@ public class RemoteNestHistoryTests [Fact] public async Task LegacyMetadataAndDelete_AreNeverSentToAnOldServer() { - using var http = new HttpClient(new Handler(_ => throw new InvalidOperationException("Unsafe mutation"))); + var mutations = 0; + using var http = new HttpClient(new Handler(request => + { + if (request.Method != HttpMethod.Get) mutations++; + return new HttpResponseMessage(HttpStatusCode.NotFound); + })); using var repository = new RemoteNestRepository(http, "http://legacy/"); - await Assert.ThrowsAsync(() => repository.UpdateMetadataAsync(Guid.NewGuid(), new NestRecord())); - await Assert.ThrowsAsync(() => repository.DeleteAsync(Guid.NewGuid())); + await Assert.ThrowsAsync(() => repository.UpdateMetadataAsync(Guid.NewGuid(), new NestRecord { Revision = 1 })); + await Assert.ThrowsAsync(() => repository.DeleteAsync(Guid.NewGuid(), 1)); + Assert.Equal(0, mutations); } private static HttpResponseMessage History(Guid id) => Json(new NestRevisionPage