mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-12 16:34:12 -04:00
feat(server): preserve deleted nests with guarded recovery
This commit is contained in:
1 parent
d216bcd406
commit
5598804942
9 files changed
+267
-50
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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; } = "";
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user