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 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(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> ExportCollections(CancellationToken cancellationToken) { var collections = new Dictionary>(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 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(); 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(); } }