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
|
|
@ -1,8 +1,13 @@
|
||||||
@page "/settings"
|
@page "/settings"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using WorkTracker.Services.Storage
|
||||||
|
|
||||||
@inject IAppSettingsService AppSettingsService
|
@inject IAppSettingsService AppSettingsService
|
||||||
|
@inject IDatabaseBackupService DatabaseBackupService
|
||||||
@inject AppThemeState ThemeState
|
@inject AppThemeState ThemeState
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>Settings</PageTitle>
|
<PageTitle>Settings</PageTitle>
|
||||||
|
|
||||||
|
|
@ -65,11 +70,60 @@ else
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
|
<section class="mt-5">
|
||||||
|
<h2 class="h4">Database backup</h2>
|
||||||
|
<p class="text-muted mb-3">Export the full database as JSON or restore a previously exported JSON backup. Restore replaces the current database only when the backup format version and database schema version are supported.</p>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
|
||||||
|
<a class="btn btn-outline-secondary" href="/api/database-backup/export">Export JSON backup</a>
|
||||||
|
<span class="small text-muted">Current database schema version: @DatabaseSchemaVersion</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-danger-subtle">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="h6 card-title">Restore from JSON</h3>
|
||||||
|
<p class="card-text text-muted">This overwrites the existing database with the selected backup file.</p>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-3 align-items-start">
|
||||||
|
<InputFile OnChange="OnRestoreFileSelected" accept="application/json,.json" />
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(selectedBackupFileName))
|
||||||
|
{
|
||||||
|
<div class="small text-muted">Selected file: @selectedBackupFileName</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="btn btn-outline-danger" type="button" @onclick="RestoreAsync" disabled="@(selectedBackupFile is null || isRestoring)">
|
||||||
|
@(isRestoring ? "Restoring..." : "Restore JSON backup")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(backupStatusMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-2 mt-3 mb-0">@backupStatusMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(backupErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 mt-3 mb-0">@backupErrorMessage</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
private const long MaxBackupFileSize = 20 * 1024 * 1024;
|
||||||
|
|
||||||
private AppSettingsDocument? settings;
|
private AppSettingsDocument? settings;
|
||||||
private string? statusMessage;
|
private string? statusMessage;
|
||||||
|
private string? backupStatusMessage;
|
||||||
|
private string? backupErrorMessage;
|
||||||
|
private IBrowserFile? selectedBackupFile;
|
||||||
|
private string? selectedBackupFileName;
|
||||||
|
private bool isRestoring;
|
||||||
|
|
||||||
|
private int DatabaseSchemaVersion => CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -87,4 +141,56 @@ else
|
||||||
ThemeState.SetThemeMode(settings.ThemeMode);
|
ThemeState.SetThemeMode(settings.ThemeMode);
|
||||||
statusMessage = $"Saved at {DateTime.Now:t}";
|
statusMessage = $"Saved at {DateTime.Now:t}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnRestoreFileSelected(InputFileChangeEventArgs args)
|
||||||
|
{
|
||||||
|
selectedBackupFile = args.File;
|
||||||
|
selectedBackupFileName = args.File.Name;
|
||||||
|
backupStatusMessage = null;
|
||||||
|
backupErrorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RestoreAsync()
|
||||||
|
{
|
||||||
|
if (selectedBackupFile is null)
|
||||||
|
{
|
||||||
|
backupErrorMessage = "Select a JSON backup file first.";
|
||||||
|
backupStatusMessage = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmed = await JS.InvokeAsync<bool>("confirm", "Restore this backup and overwrite the current database?\nThis cannot be undone.");
|
||||||
|
if (!confirmed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRestoring = true;
|
||||||
|
backupStatusMessage = null;
|
||||||
|
backupErrorMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = selectedBackupFile.OpenReadStream(MaxBackupFileSize);
|
||||||
|
await DatabaseBackupService.ImportAsync(stream);
|
||||||
|
|
||||||
|
settings = await AppSettingsService.GetAsync();
|
||||||
|
ThemeState.SetThemeMode(settings.ThemeMode);
|
||||||
|
selectedBackupFile = null;
|
||||||
|
selectedBackupFileName = null;
|
||||||
|
backupStatusMessage = $"Backup restored at {DateTime.Now:t}.";
|
||||||
|
}
|
||||||
|
catch (DatabaseBackupException exception)
|
||||||
|
{
|
||||||
|
backupErrorMessage = exception.Message;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
backupErrorMessage = "The selected backup file is too large or could not be read.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isRestoring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ builder.Services.Configure<CouchbaseLiteOptions>(builder.Configuration.GetSectio
|
||||||
builder.Services.Configure<SingleUserOptions>(builder.Configuration.GetSection(SingleUserOptions.SectionName));
|
builder.Services.Configure<SingleUserOptions>(builder.Configuration.GetSection(SingleUserOptions.SectionName));
|
||||||
|
|
||||||
builder.Services.AddSingleton<CouchbaseLiteDatabaseProvider>();
|
builder.Services.AddSingleton<CouchbaseLiteDatabaseProvider>();
|
||||||
|
builder.Services.AddScoped<IDatabaseBackupService, JsonDatabaseBackupService>();
|
||||||
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
|
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
|
||||||
builder.Services.AddScoped<AppThemeState>();
|
builder.Services.AddScoped<AppThemeState>();
|
||||||
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
||||||
|
|
@ -264,6 +265,14 @@ app.MapGet("/api/monthly-timesheet/{year:int}/{month:int}/excel", async (
|
||||||
return Results.File(file.Content, file.ContentType, file.FileName);
|
return Results.File(file.Content, file.ContentType, file.FileName);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/api/database-backup/export", async (
|
||||||
|
IDatabaseBackupService backupService,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var file = await backupService.ExportAsync(cancellationToken);
|
||||||
|
return Results.File(file.Content, file.ContentType, file.FileName);
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
|
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,15 @@ namespace WorkTracker.Services.Storage;
|
||||||
public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
||||||
{
|
{
|
||||||
private const string AppSettingsCollectionName = "app_settings";
|
private const string AppSettingsCollectionName = "app_settings";
|
||||||
|
private const string SystemCollectionName = "system";
|
||||||
private const string UsersCollectionName = "users";
|
private const string UsersCollectionName = "users";
|
||||||
private const string WorkDaysCollectionName = "workdays";
|
private const string WorkDaysCollectionName = "workdays";
|
||||||
|
private const string SchemaVersionDocumentId = "database_schema";
|
||||||
|
|
||||||
|
public const int CurrentDatabaseSchemaVersion = 1;
|
||||||
|
|
||||||
private readonly Database database;
|
private readonly Database database;
|
||||||
|
private readonly IReadOnlyDictionary<string, Collection> backupCollections;
|
||||||
|
|
||||||
public CouchbaseLiteDatabaseProvider(IOptions<CouchbaseLiteOptions> options, IHostEnvironment environment)
|
public CouchbaseLiteDatabaseProvider(IOptions<CouchbaseLiteOptions> options, IHostEnvironment environment)
|
||||||
{
|
{
|
||||||
|
|
@ -25,17 +30,39 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
||||||
Directory = databaseDirectory
|
Directory = databaseDirectory
|
||||||
});
|
});
|
||||||
|
|
||||||
|
System = database.GetCollection(SystemCollectionName) ?? database.CreateCollection(SystemCollectionName);
|
||||||
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
|
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
|
||||||
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
|
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
|
||||||
WorkDays = database.GetCollection(WorkDaysCollectionName) ?? database.CreateCollection(WorkDaysCollectionName);
|
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 AppSettings { get; }
|
||||||
|
|
||||||
public Collection Users { get; }
|
public Collection Users { get; }
|
||||||
|
|
||||||
public Collection WorkDays { 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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
database.Close();
|
database.Close();
|
||||||
|
|
@ -53,4 +80,21 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
||||||
? configuredDirectory
|
? configuredDirectory
|
||||||
: Path.GetFullPath(Path.Combine(contentRootPath, 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
docs/date-range-backup-spec.md
Normal file
81
docs/date-range-backup-spec.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Date-Range Backup Spec
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Draft only.
|
||||||
|
- Do not implement until full-backup import/export has been exercised in real usage.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow users to export and import only a selected time range without weakening the safety guarantees introduced by full JSON backups.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No implementation in this change.
|
||||||
|
- No automatic migration across JSON backup format versions.
|
||||||
|
- No merging logic for conflicting edits yet.
|
||||||
|
|
||||||
|
## Proposed backup modes
|
||||||
|
|
||||||
|
- `full`: current behavior, exports every managed collection and can fully replace the database.
|
||||||
|
- `dateRange`: future behavior, exports only documents relevant to a user-selected inclusive date interval.
|
||||||
|
|
||||||
|
## Proposed JSON shape changes
|
||||||
|
|
||||||
|
Add a top-level `scope` object while keeping the existing version markers.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backupFormatVersion": 1,
|
||||||
|
"databaseSchemaVersion": 1,
|
||||||
|
"exportedAtUtc": "2026-04-24T12:00:00Z",
|
||||||
|
"scope": {
|
||||||
|
"mode": "full"
|
||||||
|
},
|
||||||
|
"collections": {
|
||||||
|
"app_settings": [],
|
||||||
|
"users": [],
|
||||||
|
"workdays": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Future date-range backups would use:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scope": {
|
||||||
|
"mode": "dateRange",
|
||||||
|
"from": "2026-04-01",
|
||||||
|
"to": "2026-04-30"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selection rules for `dateRange`
|
||||||
|
|
||||||
|
- `workdays`: include documents whose logical day falls within `from` and `to` inclusive.
|
||||||
|
- `app_settings`: exclude by default. Settings are global, not time-scoped.
|
||||||
|
- `users`: exclude by default. Authentication data is global, not time-scoped.
|
||||||
|
- Future collections must declare whether they are `global`, `timeScoped`, or `derived` before they participate in range backups.
|
||||||
|
|
||||||
|
## Import semantics for `dateRange`
|
||||||
|
|
||||||
|
- Import must not reuse the current full-overwrite flow.
|
||||||
|
- Import must require a different confirmation message than full restore.
|
||||||
|
- Import must define conflict rules before implementation.
|
||||||
|
- Preferred first version: replace only the selected date range inside time-scoped collections.
|
||||||
|
- Global collections must remain untouched unless explicitly opted in by the user and supported by the backup format version.
|
||||||
|
|
||||||
|
## Compatibility and versioning requirements
|
||||||
|
|
||||||
|
- Keep `backupFormatVersion` and `databaseSchemaVersion` mandatory for every backup mode.
|
||||||
|
- If `scope.mode` is unknown, reject import.
|
||||||
|
- If `backupFormatVersion` is unsupported, reject import and surface that a migration/importer is required.
|
||||||
|
- If `databaseSchemaVersion` is unsupported, reject import and surface that a schema migration is required.
|
||||||
|
- When date-range import is implemented, version-specific import handlers should be introduced behind a dispatch layer such as `ImportV1Full`, `ImportV1DateRange`, and future `ImportV2*` handlers.
|
||||||
|
|
||||||
|
## UI requirements for future work
|
||||||
|
|
||||||
|
- Add date pickers for `from` and `to`.
|
||||||
|
- Validate `from <= to` before export/import is enabled.
|
||||||
|
- Show a summary of what will be affected before confirming import.
|
||||||
|
- Keep full-backup export/import available as the safer recovery path.
|
||||||
167
tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs
Normal file
167
tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Couchbase.Lite;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using WorkTracker.Configuration;
|
||||||
|
using WorkTracker.Domain;
|
||||||
|
using WorkTracker.Services.Settings;
|
||||||
|
using WorkTracker.Services.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace WorkTracker.Tests;
|
||||||
|
|
||||||
|
public sealed class JsonDatabaseBackupServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ExportAndImport_RoundTripsManagedCollectionsAndVersions()
|
||||||
|
{
|
||||||
|
using var sourceDatabase = new TestDatabaseHandle();
|
||||||
|
await SeedAsync(sourceDatabase.Provider);
|
||||||
|
|
||||||
|
var backupService = new JsonDatabaseBackupService(sourceDatabase.Provider);
|
||||||
|
var backupFile = await backupService.ExportAsync();
|
||||||
|
|
||||||
|
using var backupJson = JsonDocument.Parse(backupFile.Content);
|
||||||
|
Assert.Equal(1, backupJson.RootElement.GetProperty("backupFormatVersion").GetInt32());
|
||||||
|
Assert.Equal(CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion, backupJson.RootElement.GetProperty("databaseSchemaVersion").GetInt32());
|
||||||
|
Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("app_settings", out _));
|
||||||
|
Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("users", out _));
|
||||||
|
Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("workdays", out _));
|
||||||
|
|
||||||
|
using var targetDatabase = new TestDatabaseHandle();
|
||||||
|
var importService = new JsonDatabaseBackupService(targetDatabase.Provider);
|
||||||
|
|
||||||
|
await using (var stream = new MemoryStream(backupFile.Content, writable: false))
|
||||||
|
{
|
||||||
|
await importService.ImportAsync(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
var appSettingsService = new CouchbaseLiteAppSettingsService(targetDatabase.Provider);
|
||||||
|
var importedSettings = await appSettingsService.GetAsync();
|
||||||
|
|
||||||
|
Assert.Equal(AppThemeMode.Dark, importedSettings.ThemeMode);
|
||||||
|
Assert.Equal("USD", importedSettings.Currency);
|
||||||
|
Assert.Equal("en-US", importedSettings.Locale);
|
||||||
|
Assert.Equal(CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion, targetDatabase.Provider.DatabaseSchemaVersion);
|
||||||
|
|
||||||
|
var importedUser = targetDatabase.Provider.Users.GetDocument("USER1");
|
||||||
|
Assert.NotNull(importedUser);
|
||||||
|
Assert.Equal("admin", importedUser!.GetString("username"));
|
||||||
|
|
||||||
|
var importedWorkDay = targetDatabase.Provider.WorkDays.GetDocument("2026-04-24");
|
||||||
|
Assert.NotNull(importedWorkDay);
|
||||||
|
Assert.Equal("2026-04-24", importedWorkDay!.GetString("date"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_WhenSchemaVersionDoesNotMatch_RejectsWithoutOverwritingDatabase()
|
||||||
|
{
|
||||||
|
using var sourceDatabase = new TestDatabaseHandle();
|
||||||
|
await SeedAsync(sourceDatabase.Provider);
|
||||||
|
|
||||||
|
var backupService = new JsonDatabaseBackupService(sourceDatabase.Provider);
|
||||||
|
var backupFile = await backupService.ExportAsync();
|
||||||
|
|
||||||
|
var modifiedBackup = JsonNode.Parse(backupFile.Content)!;
|
||||||
|
modifiedBackup["databaseSchemaVersion"] = CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion + 1;
|
||||||
|
var modifiedContent = Encoding.UTF8.GetBytes(modifiedBackup.ToJsonString(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||||
|
|
||||||
|
using var targetDatabase = new TestDatabaseHandle();
|
||||||
|
SeedExistingTargetData(targetDatabase.Provider);
|
||||||
|
var importService = new JsonDatabaseBackupService(targetDatabase.Provider);
|
||||||
|
|
||||||
|
await using var stream = new MemoryStream(modifiedContent, writable: false);
|
||||||
|
var exception = await Assert.ThrowsAsync<DatabaseBackupException>(() => importService.ImportAsync(stream));
|
||||||
|
|
||||||
|
Assert.Contains("not supported", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.NotNull(targetDatabase.Provider.Users.GetDocument("KEEP"));
|
||||||
|
Assert.Null(targetDatabase.Provider.Users.GetDocument("USER1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedAsync(CouchbaseLiteDatabaseProvider provider)
|
||||||
|
{
|
||||||
|
var appSettingsService = new CouchbaseLiteAppSettingsService(provider);
|
||||||
|
await appSettingsService.SaveAsync(new AppSettingsDocument
|
||||||
|
{
|
||||||
|
ThemeMode = AppThemeMode.Dark,
|
||||||
|
Currency = "USD",
|
||||||
|
Locale = "en-US",
|
||||||
|
StandardWorkHoursPerDay = 7.5m,
|
||||||
|
HourlyGrossRate = 30m,
|
||||||
|
ProfitabilityCoefficient = 0.8m,
|
||||||
|
InpsRate = 0.2m,
|
||||||
|
SubstituteTaxRate = 0.1m
|
||||||
|
});
|
||||||
|
|
||||||
|
var userDocument = new MutableDocument("USER1");
|
||||||
|
userDocument.SetString("username", "admin");
|
||||||
|
userDocument.SetString("usernameNormalized", "ADMIN");
|
||||||
|
userDocument.SetString("passwordHash", "hash");
|
||||||
|
userDocument.SetBoolean("mustChangePassword", false);
|
||||||
|
provider.Users.Save(userDocument);
|
||||||
|
|
||||||
|
var workDayDocument = new MutableDocument("2026-04-24");
|
||||||
|
workDayDocument.SetString("date", "2026-04-24");
|
||||||
|
workDayDocument.SetBoolean("isWeekend", false);
|
||||||
|
workDayDocument.SetBoolean("isItalianFestivity", false);
|
||||||
|
workDayDocument.SetArray("workUnits", new MutableArrayObject());
|
||||||
|
workDayDocument.SetArray("calendarEvents", new MutableArrayObject());
|
||||||
|
workDayDocument.SetString("createdAtUtc", "2026-04-24T08:00:00.0000000+00:00");
|
||||||
|
workDayDocument.SetString("updatedAtUtc", "2026-04-24T08:00:00.0000000+00:00");
|
||||||
|
provider.WorkDays.Save(workDayDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedExistingTargetData(CouchbaseLiteDatabaseProvider provider)
|
||||||
|
{
|
||||||
|
var existingUser = new MutableDocument("KEEP");
|
||||||
|
existingUser.SetString("username", "keep-me");
|
||||||
|
existingUser.SetString("usernameNormalized", "KEEP-ME");
|
||||||
|
existingUser.SetString("passwordHash", "keep-hash");
|
||||||
|
existingUser.SetBoolean("mustChangePassword", false);
|
||||||
|
provider.Users.Save(existingUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestDatabaseHandle : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string directoryPath;
|
||||||
|
|
||||||
|
public TestDatabaseHandle()
|
||||||
|
{
|
||||||
|
directoryPath = Path.Combine(Path.GetTempPath(), "WorkTracker.Tests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
|
||||||
|
Provider = new CouchbaseLiteDatabaseProvider(
|
||||||
|
Options.Create(new CouchbaseLiteOptions
|
||||||
|
{
|
||||||
|
Directory = directoryPath,
|
||||||
|
DatabaseName = $"worktracker-tests-{Guid.NewGuid():N}"
|
||||||
|
}),
|
||||||
|
new TestHostEnvironment());
|
||||||
|
}
|
||||||
|
|
||||||
|
public CouchbaseLiteDatabaseProvider Provider { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Provider.Dispose();
|
||||||
|
|
||||||
|
if (Directory.Exists(directoryPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(directoryPath, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestHostEnvironment : IHostEnvironment
|
||||||
|
{
|
||||||
|
public string EnvironmentName { get; set; } = "Development";
|
||||||
|
|
||||||
|
public string ApplicationName { get; set; } = "WorkTracker.Tests";
|
||||||
|
|
||||||
|
public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
|
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue