mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 16:01:55 -04:00
feat(server): restore historical nest as new revision
This commit is contained in:
1 parent
5598804942
commit
914c197181
8 files changed
+242
-17
No files matched your search
@@ -68,6 +68,10 @@ public interface INestRepository
|
||||
Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("This repository does not support guarded deletion.");
|
||||
|
||||
Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("This repository does not support guarded restoration.");
|
||||
|
||||
Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException("This repository does not support guarded recovery.");
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class NestRecord
|
||||
|
||||
public bool Deleted { get; set; }
|
||||
|
||||
/// <summary>Selected historical revision promoted by an explicit Restore.</summary>
|
||||
public int? RestoredFromRevision { get; set; }
|
||||
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Customer { get; set; } = "";
|
||||
|
||||
@@ -236,6 +236,21 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
return await ReadRecord(response, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<NestRecord> 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<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
||||
ChangeVisibilityAsync(id, expectedRevision, true, cancellationToken);
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<IOException>(() => 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");
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<IOException>(() => repository.UpdateMetadataAsync(Guid.NewGuid(), new NestRecord()));
|
||||
await Assert.ThrowsAsync<IOException>(() => repository.DeleteAsync(Guid.NewGuid()));
|
||||
await Assert.ThrowsAsync<IOException>(() => repository.UpdateMetadataAsync(Guid.NewGuid(), new NestRecord { Revision = 1 }));
|
||||
await Assert.ThrowsAsync<IOException>(() => repository.DeleteAsync(Guid.NewGuid(), 1));
|
||||
Assert.Equal(0, mutations);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage History(Guid id) => Json(new NestRevisionPage
|
||||
|
||||
Reference in new issue
Block a user