feat(server): preserve deleted nests with guarded recovery

This commit is contained in:
aj committed 2026-10-11 16:49:12 -04:00
1 parent d216bcd406
commit 5598804942
9 files changed
+267 -50

No files matched your search

+23
View File
@@ -48,6 +48,29 @@ public interface INestRepository
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded metadata edits.");
Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nest history.");
Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nest history.");
Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded deletion.");
Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded recovery.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
+2
View File
@@ -14,6 +14,8 @@ public sealed class NestRecord
/// <summary>Server revision; zero means the server does not support history.</summary>
public int Revision { get; set; }
public bool Deleted { get; set; }
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
+63 -12
View File
@@ -59,7 +59,13 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return items ?? new List<NestRecord>();
}
public async Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
QueryCoreAsync(query, false, cancellationToken);
public Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
QueryCoreAsync(query, true, cancellationToken);
private async Task<NestPage> QueryCoreAsync(NestQuery query, bool deleted, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError();
@@ -67,7 +73,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
throw new ArgumentException(error, nameof(query));
using var response = await _httpClient.GetAsync(
Url("api/nests/query?" + BuildQueryString(query)), cancellationToken);
Url((deleted ? "api/nests/deleted/query?" : "api/nests/query?") + BuildQueryString(query)), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new IOException(
@@ -93,18 +99,30 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, false, cancellationToken);
public Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, true, cancellationToken);
private async Task<NestRecord?> GetMetadataCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
}
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default)
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
GetFileCoreAsync(id, false, cancellationToken);
public Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
GetFileCoreAsync(id, true, cancellationToken);
private async Task<byte[]?> GetFileCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken);
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
@@ -138,14 +156,20 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
throw new IOException("Deletion requires guarded soft-delete support and is not available yet. Upgrade the client before deleting a server nest.");
}
public async Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default)
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, false, cancellationToken);
public Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, true, cancellationToken);
private async Task<NestRevisionPage> GetHistoryAsync(Guid id, int offset, int limit, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > NestRevisionPage.MaxLimit) throw new ArgumentOutOfRangeException(nameof(limit));
using var response = await _httpClient.GetAsync(
Url($"api/nests/{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload.");
await EnsureSuccess(response, $"read nest history {id}", cancellationToken);
@@ -159,12 +183,18 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return page;
}
public async Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default)
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, false, cancellationToken);
public Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, true, cancellationToken);
private async Task<byte[]?> GetHistoricalFileAsync(Guid id, int revisionNumber, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
ValidateRevision(revisionNumber);
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken);
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccess(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
@@ -206,6 +236,27 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return await ReadRecord(response, cancellationToken);
}
public Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
ChangeVisibilityAsync(id, expectedRevision, true, cancellationToken);
public Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
ChangeVisibilityAsync(id, expectedRevision, false, cancellationToken);
private async Task<NestRecord> ChangeVisibilityAsync(Guid id, int expectedRevision, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
ValidateRevision(expectedRevision);
// A legacy server cannot be trusted to preserve history on DELETE.
await GetHistoryAsync(id, 0, 1, !deleted, cancellationToken);
using var request = new HttpRequestMessage(deleted ? HttpMethod.Delete : HttpMethod.Post,
Url(deleted ? $"api/nests/{id}" : $"api/nests/deleted/{id}/recover"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
private static void ValidateId(Guid id)
{
if (id == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(id));
+2 -1
View File
@@ -142,8 +142,9 @@ public sealed class NestApiTests
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.BadRequest, metadata.StatusCode);
server.Client.DefaultRequestHeaders.IfMatch.Clear();
using var delete = await server.Client.DeleteAsync($"/api/nests/{created.Id}");
Assert.Equal(HttpStatusCode.NotImplemented, delete.StatusCode);
Assert.Equal((HttpStatusCode)428, delete.StatusCode);
Assert.Equal(before, await server.SnapshotAsync());
}
@@ -18,6 +18,7 @@ public sealed class NestDatabaseConcurrencyTests
[InlineData("Insert")]
[InlineData("UpdateFile")]
[InlineData("UpdateMetadata")]
[InlineData("SetDeleted")]
[InlineData("History")]
[InlineData("GetRevisionFile")]
[InlineData("Health")]
@@ -47,6 +48,7 @@ public sealed class NestDatabaseConcurrencyTests
case "Insert": database.Insert(Guid.NewGuid(), record, new byte[] { 4 }); break;
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 "History": database.History(id, 0, 100); break;
case "GetRevisionFile": database.GetRevisionFile(id, 1); break;
case "Health": Assert.True(database.IsHealthy()); break;
@@ -109,6 +111,13 @@ public sealed class NestDatabaseConcurrencyTests
() => database.History(id, 0, 100),
() => Assert.True(database.IsHealthy()),
() => database.GetRevisionFile(id, 1),
() => database.SetDeleted(id, true, 2),
() => database.Query(new NestQuery(), true),
() => database.Get(id, true),
() => database.GetFile(id, true),
() => database.History(id, 0, 100, true),
() => database.GetRevisionFile(id, 1, true),
() => database.SetDeleted(id, false, 3),
];
foreach (var operation in operations)
{
@@ -63,6 +63,84 @@ public sealed class NestMutationTests
Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total);
}
[Fact]
public async Task Delete_HidesAllOrdinaryReadsAndRecoveryPreservesVersions()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
var saved = await repository.UploadAsync([0, 255], new NestRecord { Name = "Fixture" });
var deleted = await repository.DeleteAsync(saved.Id, 1);
Assert.True(deleted.Deleted);
Assert.Equal(2, deleted.Revision);
foreach (var suffix in new[] { "", "/file", "/revisions", "/revisions/1/file" })
{
using var hidden = await client.GetAsync($"/api/nests/{saved.Id}{suffix}");
Assert.Equal(HttpStatusCode.NotFound, hidden.StatusCode);
}
Assert.Empty(await repository.ListAsync());
Assert.Empty((await repository.QueryAsync(new NestQuery())).Items);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Null(reopened.Get(saved.Id));
// Recovery is explicit and bounded, never ordinary search.
Assert.Equal(saved.Id, Assert.Single((await repository.QueryDeletedAsync(new NestQuery { Limit = 1 })).Items).Id);
Assert.True((await repository.GetDeletedMetadataAsync(saved.Id))!.Deleted);
Assert.Equal(new byte[] { 0, 255 }, await repository.GetDeletedFileAsync(saved.Id));
var history = await repository.GetDeletedRevisionsAsync(saved.Id);
Assert.Equal(new[] { true, false }, history.Items.Select(r => r.Deleted));
foreach (var item in history.Items)
Assert.Equal(new byte[] { 0, 255 }, await repository.GetDeletedRevisionFileAsync(saved.Id, item.Revision));
var recovered = await repository.RecoverAsync(saved.Id, 2);
Assert.False(recovered.Deleted);
Assert.Empty((await repository.QueryDeletedAsync(new NestQuery())).Items);
Assert.Equal(3, (await repository.GetMetadataAsync(saved.Id))!.Revision);
Assert.Equal(new byte[] { 0, 255 }, await repository.GetFileAsync(saved.Id));
Assert.Equal(3, (await repository.GetRevisionsAsync(saved.Id)).Total);
}
[Theory]
[InlineData(false, null, 428)]
[InlineData(false, "1", 400)]
[InlineData(false, "\"2\"", 409)]
[InlineData(true, null, 428)]
[InlineData(true, "1", 400)]
[InlineData(true, "\"1\"", 409)]
public async Task Visibility_RejectedWriteLeavesBytesAndHistory(bool recover, string? header, 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" });
if (recover) await repository.DeleteAsync(saved.Id, 1);
using var request = new HttpRequestMessage(recover ? HttpMethod.Post : HttpMethod.Delete,
recover ? $"/api/nests/deleted/{saved.Id}/recover" : $"/api/nests/{saved.Id}");
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(recover ? 2 : 1, reopened.History(saved.Id, 0, 100, recover)!.Total);
Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover));
Assert.Equal(recover, reopened.Get(saved.Id, recover)!.Deleted);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Visibility_FailedSnapshotRollsBack(bool recover)
{
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 (recover) await repository.DeleteAsync(saved.Id, 1);
FailSnapshots(factory.DatabasePath);
Assert.Contains("500", (await Assert.ThrowsAsync<IOException>(() => recover
? repository.RecoverAsync(saved.Id, 2) : repository.DeleteAsync(saved.Id, 1))).Message);
using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(recover ? 2 : 1, reopened.History(saved.Id, 0, 100, recover)!.Total);
Assert.Equal(new byte[] { 1 }, reopened.GetFile(saved.Id, recover));
}
private static void FailSnapshots(string path)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
@@ -187,7 +187,6 @@ public sealed class NestRevisionTests
[InlineData("metadata", null, 428)]
[InlineData("delete", null, 428)]
[InlineData("metadata", "\"1\"", 400)]
[InlineData("delete", "\"1\"", 501)]
[InlineData("file", "\"1\"", 400)]
public async Task BlockedOrMalformedWrite_PreservesAllSnapshots(string route, string? header, int status)
{
+48 -13
View File
@@ -63,7 +63,7 @@ public sealed class NestDatabase : IDisposable
{
using var command = _connection.CreateCommand();
command.CommandText =
$"SELECT {RecordColumns} FROM nests ORDER BY savedAt DESC";
$"SELECT {RecordColumns} FROM nests WHERE deleted=0 ORDER BY savedAt DESC";
var records = new List<NestRecord>();
using var reader = command.ExecuteReader();
while (reader.Read())
@@ -72,12 +72,13 @@ public sealed class NestDatabase : IDisposable
}
}
public NestRecord? Get(Guid id)
public NestRecord? Get(Guid id, bool deleted = false)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id";
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id AND deleted=$deleted";
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$id", id.ToString());
using var reader = command.ExecuteReader();
return reader.Read() ? ReadRecord(reader) : null;
@@ -88,7 +89,7 @@ public sealed class NestDatabase : IDisposable
/// One bounded page of matching metadata plus the total match count. The count and
/// the page are read in the same monitor hold, so they describe one state.
/// </summary>
public NestPage Query(NestQuery query)
public NestPage Query(NestQuery query, bool deleted = false)
{
ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError();
@@ -96,7 +97,7 @@ public sealed class NestDatabase : IDisposable
throw new ArgumentException(error, nameof(query));
var search = query.NormalizedSearch;
var where = search.Length == 0 ? "" : SearchPredicate;
var where = "WHERE deleted=" + (deleted ? "1" : "0") + (search.Length == 0 ? "" : " AND (" + SearchPredicate + ")");
lock (_sync)
{
using var count = _connection.CreateCommand();
@@ -123,12 +124,13 @@ public sealed class NestDatabase : IDisposable
}
}
public byte[]? GetFile(Guid id)
public byte[]? GetFile(Guid id, bool deleted = false)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = "SELECT file FROM nests WHERE id = $id";
command.CommandText = "SELECT file FROM nests WHERE id = $id AND deleted=$deleted";
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$id", id.ToString());
var value = command.ExecuteScalar();
return value is byte[] bytes ? bytes : null;
@@ -197,12 +199,34 @@ public sealed class NestDatabase : IDisposable
}
}
public NestPage? History(Guid id, int offset, int limit)
public NestRecord? SetDeleted(Guid id, bool deleted, int expectedRevision)
{
lock (_sync)
{
using var transaction = _connection.BeginTransaction();
var current = Get(id, !deleted);
if (current is null) return null;
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.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$deleted", deleted ? 1 : 0);
command.Parameters.AddWithValue("$revision", checked(current.Revision + 1));
command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o"));
command.ExecuteNonQuery();
Snapshot(id, transaction);
transaction.Commit();
return Get(id, deleted);
}
}
public NestPage? History(Guid id, int offset, int limit, bool deleted = false)
{
if (offset < 0 || limit is < 1 or > 200) throw new ArgumentOutOfRangeException(nameof(limit));
lock (_sync)
{
if (Get(id) is null) return null;
if (Get(id, deleted) 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());
@@ -219,11 +243,12 @@ public sealed class NestDatabase : IDisposable
}
}
public byte[]? GetRevisionFile(Guid id, int revision)
public byte[]? GetRevisionFile(Guid id, int revision, bool deleted = false)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
if (Get(id, deleted) is null) return null;
command.CommandText = "SELECT file FROM nest_revisions WHERE id=$id AND revision=$revision";
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$revision", revision);
@@ -255,7 +280,7 @@ public sealed class NestDatabase : IDisposable
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 > 2) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -267,6 +292,15 @@ public sealed class NestDatabase : IDisposable
""";
command.ExecuteNonQuery();
}
if (version < 2)
{
command.CommandText = """
ALTER TABLE nests ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
ALTER TABLE nest_revisions ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
PRAGMA user_version=2;
""";
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
@@ -304,13 +338,13 @@ public sealed class NestDatabase : IDisposable
private const string RecordColumns = """
id, name, customer, dateCreated, dateModified, material, thickness,
status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision
status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted
""";
// Case-insensitive (ASCII) substring over the text columns and the status's stored
// and display names. The search text is always a bound, escaped LIKE parameter.
private const string SearchPredicate = """
WHERE name LIKE $pattern ESCAPE '\'
name LIKE $pattern ESCAPE '\'
OR customer LIKE $pattern ESCAPE '\'
OR material LIKE $pattern ESCAPE '\'
OR madeBy LIKE $pattern ESCAPE '\'
@@ -391,6 +425,7 @@ public sealed class NestDatabase : IDisposable
FileSize = reader.GetInt64(12),
SavedAt = DateTime.Parse(reader.GetString(13)),
Revision = reader.GetInt32(14),
Deleted = reader.GetInt32(15) != 0,
};
}
}
+42 -23
View File
@@ -63,23 +63,7 @@ app.MapPost("/api/nests", async (HttpContext http, NestDatabase db) =>
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();
});
ReadHistory(id, request, db));
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());
@@ -118,7 +102,21 @@ app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpRequest request,
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
});
app.MapDelete("/api/nests/{id:guid}", (Guid id, HttpRequest request, NestDatabase db) =>
BlockDeferredMutation(id, request, db));
ChangeVisibility(id, true, request, db));
app.MapPost("/api/nests/deleted/{id:guid}/recover", (Guid id, HttpRequest request, NestDatabase db) =>
ChangeVisibility(id, false, request, db));
app.MapGet("/api/nests/deleted/query", (HttpRequest request, NestDatabase db) =>
NestQueryRequest.TryParse(request.Query, out var query, out var error)
? Results.Ok(db.Query(query, deleted: true)) : Results.BadRequest(error));
app.MapGet("/api/nests/deleted/{id:guid}", (Guid id, NestDatabase db) =>
db.Get(id, deleted: true) is { } record ? Results.Ok(record) : Results.NotFound());
app.MapGet("/api/nests/deleted/{id:guid}/file", (Guid id, NestDatabase db) =>
db.GetFile(id, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound());
app.MapGet("/api/nests/deleted/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
ReadHistory(id, request, db, deleted: true));
app.MapGet("/api/nests/deleted/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
revision < 1 ? Results.BadRequest("Invalid revision.") :
db.GetRevisionFile(id, revision, deleted: true) is { } file ? Results.File(file, "application/zip") : Results.NotFound());
app.Run();
@@ -135,14 +133,35 @@ static IResult? ReadPrecondition(HttpRequest request, out int revision)
return null;
}
static IResult BlockDeferredMutation(Guid id, HttpRequest request, NestDatabase db)
static IResult ChangeVisibility(Guid id, bool deleted, 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);
try
{
var changed = db.SetDeleted(id, deleted, expected);
return changed is null ? Results.NotFound() : Results.Ok(changed);
}
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
}
static IResult ReadHistory(Guid id, HttpRequest request, NestDatabase db, bool deleted = false)
{
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, deleted) is { } page ? Results.Ok(page) : Results.NotFound();
}
// Shared multipart reader for upload endpoints. Returns a (record, file, error) triple.