feat: implement database backup and restore functionality with JSON support
All checks were successful
Publish Container / publish (push) Successful in 3m53s
All checks were successful
Publish Container / publish (push) Successful in 3m53s
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
e872fe200b
commit
e8bbae0496
8 changed files with 657 additions and 0 deletions
204
Services/Storage/JsonDatabaseBackupService.cs
Normal file
204
Services/Storage/JsonDatabaseBackupService.cs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Couchbase.Lite;
|
||||
using Couchbase.Lite.Query;
|
||||
|
||||
namespace WorkTracker.Services.Storage;
|
||||
|
||||
public sealed class JsonDatabaseBackupService(CouchbaseLiteDatabaseProvider databaseProvider) : IDatabaseBackupService
|
||||
{
|
||||
private const int CurrentBackupFormatVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public Task<DatabaseBackupFile> ExportAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureSupportedDatabaseSchemaVersion(databaseProvider.DatabaseSchemaVersion);
|
||||
|
||||
var payload = new DatabaseBackupPayload
|
||||
{
|
||||
BackupFormatVersion = CurrentBackupFormatVersion,
|
||||
DatabaseSchemaVersion = databaseProvider.DatabaseSchemaVersion,
|
||||
ExportedAtUtc = DateTimeOffset.UtcNow,
|
||||
Collections = ExportCollections(cancellationToken)
|
||||
};
|
||||
|
||||
var content = JsonSerializer.SerializeToUtf8Bytes(payload, JsonSerializerOptions);
|
||||
var timestamp = payload.ExportedAtUtc.ToString("yyyyMMdd-HHmmss");
|
||||
|
||||
return Task.FromResult(new DatabaseBackupFile
|
||||
{
|
||||
Content = content,
|
||||
ContentType = "application/json",
|
||||
FileName = $"worktracker-backup-v{payload.DatabaseSchemaVersion}-{timestamp}.json"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task ImportAsync(Stream jsonStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(jsonStream);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
DatabaseBackupPayload payload;
|
||||
|
||||
try
|
||||
{
|
||||
payload = await JsonSerializer.DeserializeAsync<DatabaseBackupPayload>(jsonStream, JsonSerializerOptions, cancellationToken)
|
||||
?? throw new DatabaseBackupException("Backup file is empty or invalid.");
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new DatabaseBackupException($"Backup file is not valid JSON: {exception.Message}");
|
||||
}
|
||||
|
||||
ValidatePayload(payload);
|
||||
|
||||
databaseProvider.ExecuteInBatch(() =>
|
||||
{
|
||||
foreach (var collection in databaseProvider.BackupCollections.Values)
|
||||
{
|
||||
ClearCollection(collection);
|
||||
}
|
||||
|
||||
foreach (var (collectionName, documents) in payload.Collections)
|
||||
{
|
||||
var collection = databaseProvider.BackupCollections[collectionName];
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
var mutableDocument = new MutableDocument(document.Id);
|
||||
mutableDocument.SetJSON(document.Data.GetRawText());
|
||||
collection.Save(mutableDocument);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Dictionary<string, List<DatabaseBackupDocumentPayload>> ExportCollections(CancellationToken cancellationToken)
|
||||
{
|
||||
var collections = new Dictionary<string, List<DatabaseBackupDocumentPayload>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (collectionName, collection) in databaseProvider.BackupCollections)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
collections[collectionName] = GetDocuments(collection, cancellationToken)
|
||||
.Select(document => new DatabaseBackupDocumentPayload
|
||||
{
|
||||
Id = document.Id,
|
||||
Data = ParseJson(document.ToJSON())
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
private void ValidatePayload(DatabaseBackupPayload payload)
|
||||
{
|
||||
if (payload.BackupFormatVersion <= 0)
|
||||
{
|
||||
throw new DatabaseBackupException("Backup file does not declare a supported format version.");
|
||||
}
|
||||
|
||||
EnsureSupportedBackupFormatVersion(payload.BackupFormatVersion);
|
||||
EnsureSupportedDatabaseSchemaVersion(payload.DatabaseSchemaVersion);
|
||||
|
||||
if (payload.DatabaseSchemaVersion != databaseProvider.DatabaseSchemaVersion)
|
||||
{
|
||||
throw new DatabaseBackupException($"Backup schema version {payload.DatabaseSchemaVersion} does not match database schema version {databaseProvider.DatabaseSchemaVersion}.");
|
||||
}
|
||||
|
||||
var expectedCollectionNames = databaseProvider.BackupCollections.Keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
var actualCollectionNames = payload.Collections.Keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
|
||||
if (!expectedCollectionNames.SequenceEqual(actualCollectionNames, StringComparer.Ordinal))
|
||||
{
|
||||
throw new DatabaseBackupException("Backup file does not contain the expected collection set for this application version.");
|
||||
}
|
||||
|
||||
foreach (var (collectionName, documents) in payload.Collections)
|
||||
{
|
||||
foreach (var document in documents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
throw new DatabaseBackupException($"Backup contains a document without an id in collection '{collectionName}'.");
|
||||
}
|
||||
|
||||
if (document.Data.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
throw new DatabaseBackupException($"Backup document '{document.Id}' in collection '{collectionName}' must be a JSON object.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSupportedBackupFormatVersion(int backupFormatVersion)
|
||||
{
|
||||
if (backupFormatVersion == CurrentBackupFormatVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new DatabaseBackupException($"Backup format version {backupFormatVersion} is not supported by this build. Add a backup format migration before importing it.");
|
||||
}
|
||||
|
||||
private static void EnsureSupportedDatabaseSchemaVersion(int databaseSchemaVersion)
|
||||
{
|
||||
if (databaseSchemaVersion == CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new DatabaseBackupException($"Database schema version {databaseSchemaVersion} is not supported by this build. Add a schema migration before using this backup.");
|
||||
}
|
||||
|
||||
private static List<Document> GetDocuments(Collection collection, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = QueryBuilder
|
||||
.Select(SelectResult.Expression(Meta.ID))
|
||||
.From(DataSource.Collection(collection))
|
||||
.OrderBy(Ordering.Expression(Meta.ID));
|
||||
|
||||
var documents = new List<Document>();
|
||||
foreach (var result in query.Execute())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var id = result.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var document = collection.GetDocument(id);
|
||||
if (document is not null)
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static void ClearCollection(Collection collection)
|
||||
{
|
||||
foreach (var document in GetDocuments(collection, CancellationToken.None))
|
||||
{
|
||||
collection.Delete(document);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement ParseJson(string json)
|
||||
{
|
||||
using var jsonDocument = JsonDocument.Parse(json);
|
||||
return jsonDocument.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue