feat: implement database backup and restore functionality with JSON support
All checks were successful
Publish Container / publish (push) Successful in 3m53s

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Marco 2026-04-24 11:13:32 +02:00
commit e8bbae0496
8 changed files with 657 additions and 0 deletions

View file

@ -7,10 +7,15 @@ namespace WorkTracker.Services.Storage;
public sealed class CouchbaseLiteDatabaseProvider : IDisposable
{
private const string AppSettingsCollectionName = "app_settings";
private const string SystemCollectionName = "system";
private const string UsersCollectionName = "users";
private const string WorkDaysCollectionName = "workdays";
private const string SchemaVersionDocumentId = "database_schema";
public const int CurrentDatabaseSchemaVersion = 1;
private readonly Database database;
private readonly IReadOnlyDictionary<string, Collection> backupCollections;
public CouchbaseLiteDatabaseProvider(IOptions<CouchbaseLiteOptions> options, IHostEnvironment environment)
{
@ -25,17 +30,39 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
Directory = databaseDirectory
});
System = database.GetCollection(SystemCollectionName) ?? database.CreateCollection(SystemCollectionName);
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
WorkDays = database.GetCollection(WorkDaysCollectionName) ?? database.CreateCollection(WorkDaysCollectionName);
backupCollections = new Dictionary<string, Collection>(StringComparer.Ordinal)
{
[AppSettingsCollectionName] = AppSettings,
[UsersCollectionName] = Users,
[WorkDaysCollectionName] = WorkDays
};
DatabaseSchemaVersion = EnsureDatabaseSchemaVersion();
}
public Collection System { get; }
public Collection AppSettings { get; }
public Collection Users { get; }
public Collection WorkDays { get; }
public int DatabaseSchemaVersion { get; }
public IReadOnlyDictionary<string, Collection> BackupCollections => backupCollections;
public void ExecuteInBatch(Action action)
{
ArgumentNullException.ThrowIfNull(action);
database.InBatch(action);
}
public void Dispose()
{
database.Close();
@ -53,4 +80,21 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
? configuredDirectory
: Path.GetFullPath(Path.Combine(contentRootPath, configuredDirectory));
}
private int EnsureDatabaseSchemaVersion()
{
var document = System.GetDocument(SchemaVersionDocumentId);
if (document is not null && document.Contains("schemaVersion"))
{
return document.GetInt("schemaVersion");
}
var now = DateTimeOffset.UtcNow;
var mutableDocument = new MutableDocument(SchemaVersionDocumentId);
mutableDocument.SetInt("schemaVersion", CurrentDatabaseSchemaVersion);
mutableDocument.SetString("createdAtUtc", now.ToString("O"));
mutableDocument.SetString("updatedAtUtc", now.ToString("O"));
System.Save(mutableDocument);
return CurrentDatabaseSchemaVersion;
}
}

View file

@ -0,0 +1,38 @@
using System.Text.Json;
namespace WorkTracker.Services.Storage;
public sealed class DatabaseBackupFile
{
public required byte[] Content { get; init; }
public required string ContentType { get; init; }
public required string FileName { get; init; }
}
public sealed class DatabaseBackupPayload
{
public int BackupFormatVersion { get; init; }
public int DatabaseSchemaVersion { get; init; }
public DateTimeOffset ExportedAtUtc { get; init; }
public Dictionary<string, List<DatabaseBackupDocumentPayload>> Collections { get; init; } = [];
}
public sealed class DatabaseBackupDocumentPayload
{
public string Id { get; init; } = string.Empty;
public JsonElement Data { get; init; }
}
public sealed class DatabaseBackupException : Exception
{
public DatabaseBackupException(string message)
: base(message)
{
}
}

View file

@ -0,0 +1,8 @@
namespace WorkTracker.Services.Storage;
public interface IDatabaseBackupService
{
Task<DatabaseBackupFile> ExportAsync(CancellationToken cancellationToken = default);
Task ImportAsync(Stream jsonStream, CancellationToken cancellationToken = default);
}

View 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();
}
}