feat(server): restore historical nest as new revision

This commit is contained in:
aj committed 2026-10-11 16:49:12 -04:00
1 parent 5598804942
commit 914c197181
8 files changed
+242 -17

No files matched your search

+4
View File
@@ -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.");
+3
View File
@@ -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; } = "";
+15
View File
@@ -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)
{
+135
View File
@@ -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");
+60 -14
View File
@@ -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),
};
}
}
+13
View File
@@ -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