diff --git a/OpenNest.Data/INestRepository.cs b/OpenNest.Data/INestRepository.cs index 427ca42..8cdfafe 100644 --- a/OpenNest.Data/INestRepository.cs +++ b/OpenNest.Data/INestRepository.cs @@ -48,6 +48,29 @@ public interface INestRepository CancellationToken cancellationToken = default) => throw new NotSupportedException("This repository does not support guarded metadata edits."); + Task GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support deleted nests."); + + Task GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support deleted nests."); + + Task QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support deleted nests."); + + Task GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support deleted nest history."); + + Task GetDeletedRevisionFileAsync(Guid id, int revisionNumber, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support deleted nest history."); + + Task DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support guarded deletion."); + + Task RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support guarded recovery."); + /// Atomically replaces the current archive only at the expected revision. Task UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision, CancellationToken cancellationToken = default) => diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs index f207cca..c6ca828 100644 --- a/OpenNest.Data/NestRecord.cs +++ b/OpenNest.Data/NestRecord.cs @@ -14,6 +14,8 @@ public sealed class NestRecord /// Server revision; zero means the server does not support history. public int Revision { get; set; } + public bool Deleted { get; set; } + public string Name { get; set; } = ""; public string Customer { get; set; } = ""; diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs index 5ceffb7..7b9d4a1 100644 --- a/OpenNest.Data/RemoteNestRepository.cs +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -59,7 +59,13 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable return items ?? new List(); } - public async Task QueryAsync(NestQuery query, CancellationToken cancellationToken = default) + public Task QueryAsync(NestQuery query, CancellationToken cancellationToken = default) => + QueryCoreAsync(query, false, cancellationToken); + + public Task QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) => + QueryCoreAsync(query, true, cancellationToken); + + private async Task 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 GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) + public Task GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) => + GetMetadataCoreAsync(id, false, cancellationToken); + + public Task GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) => + GetMetadataCoreAsync(id, true, cancellationToken); + + private async Task 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(JsonOptions, cancellationToken); } - public async Task GetFileAsync(Guid id, CancellationToken cancellationToken = default) + public Task GetFileAsync(Guid id, CancellationToken cancellationToken = default) => + GetFileCoreAsync(id, false, cancellationToken); + + public Task GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) => + GetFileCoreAsync(id, true, cancellationToken); + + private async Task 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 GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, - CancellationToken cancellationToken = default) + public Task GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, + CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, false, cancellationToken); + + public Task GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100, + CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, true, cancellationToken); + + private async Task 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 GetRevisionFileAsync(Guid id, int revisionNumber, - CancellationToken cancellationToken = default) + public Task GetRevisionFileAsync(Guid id, int revisionNumber, + CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, false, cancellationToken); + + public Task GetDeletedRevisionFileAsync(Guid id, int revisionNumber, + CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, true, cancellationToken); + + private async Task 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 DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => + ChangeVisibilityAsync(id, expectedRevision, true, cancellationToken); + + public Task RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) => + ChangeVisibilityAsync(id, expectedRevision, false, cancellationToken); + + private async Task 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)); diff --git a/OpenNest.Server.Tests/NestApiTests.cs b/OpenNest.Server.Tests/NestApiTests.cs index 56a62ef..0257fb9 100644 --- a/OpenNest.Server.Tests/NestApiTests.cs +++ b/OpenNest.Server.Tests/NestApiTests.cs @@ -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()); } diff --git a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs index 4f0bf0b..0d12598 100644 --- a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs +++ b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs @@ -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) { diff --git a/OpenNest.Server.Tests/NestMutationTests.cs b/OpenNest.Server.Tests/NestMutationTests.cs index d571a93..14d0769 100644 --- a/OpenNest.Server.Tests/NestMutationTests.cs +++ b/OpenNest.Server.Tests/NestMutationTests.cs @@ -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(() => 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"); diff --git a/OpenNest.Server.Tests/NestRevisionTests.cs b/OpenNest.Server.Tests/NestRevisionTests.cs index 198f3ad..0c8aecd 100644 --- a/OpenNest.Server.Tests/NestRevisionTests.cs +++ b/OpenNest.Server.Tests/NestRevisionTests.cs @@ -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) { diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index b2c7b6e..d9f8658 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -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(); 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. /// - 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, }; } } diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 37681d0..b3f83c9 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -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.