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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
38
Services/Storage/DatabaseBackupModels.cs
Normal file
38
Services/Storage/DatabaseBackupModels.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
8
Services/Storage/IDatabaseBackupService.cs
Normal file
8
Services/Storage/IDatabaseBackupService.cs
Normal 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);
|
||||
}
|
||||
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