All checks were successful
Publish Container / publish (push) Successful in 3m53s
Co-authored-by: Copilot <copilot@github.com>
204 lines
No EOL
7.4 KiB
C#
204 lines
No EOL
7.4 KiB
C#
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();
|
|
}
|
|
} |