Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AStar.Dev.CloudSync.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
<Folder Name="/test/">
<Project Path="test/AStar.Dev.FunctionsParadigm.Tests.Unit/AStar.Dev.FunctionsParadigm.Tests.Unit.csproj" />
<Project Path="test/AStar.Dev.CloudSyncFunctional.Tests.Unit/AStar.Dev.CloudSyncFunctional.Tests.Unit.csproj" />
<Project Path="test/AStar.Dev.CloudSyncFunctional.Tests.Integration/AStar.Dev.CloudSyncFunctional.Tests.Integration.csproj" />
</Folder>
</Solution>
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<NoWarn>NU1903</NoWarn>
<NoWarn>NU1903;CS9113</NoWarn>
<UserSecretsId>astar-dev-cloudsync-functional</UserSecretsId>
</PropertyGroup>

Expand All @@ -33,6 +33,11 @@
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
Expand Down
35 changes: 34 additions & 1 deletion src/AStar.Dev.CloudSyncFunctional/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using AStar.Dev.CloudSyncFunctional.Auth;
using AStar.Dev.CloudSyncFunctional.Graph;
using AStar.Dev.CloudSyncFunctional.Onboarding;
using AStar.Dev.CloudSyncFunctional.Persistence;
using AStar.Dev.CloudSyncFunctional.Persistence.Repositories;
using AStar.Dev.CloudSyncFunctional.Wizard;
using AStar.Dev.CloudSyncFunctional.Workspace;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -34,6 +37,8 @@ public override void OnFrameworkInitializationCompleted()
ConfigureServices(services, configuration);
_serviceProvider = services.BuildServiceProvider();

ApplyDatabaseMigrations(_serviceProvider);

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow = new MainWindow(_serviceProvider.GetRequiredService<WorkspaceViewModel>());

Expand All @@ -58,8 +63,36 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio
services.AddSingleton<IAuthService, AuthService>();
services.AddSingleton<IGraphClientFactory, GraphClientFactory>();
services.AddSingleton<IGraphService, GraphService>();
services.AddSingleton<IAccountOnboardingService, AccountOnboardingService>();

var connectionString = $"DataSource={GetDatabasePath()}";
services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite(connectionString), ServiceLifetime.Singleton);

services.AddTransient<IAccountRepository, AccountRepository>();
services.AddTransient<ISyncRuleRepository, SyncRuleRepository>();
services.AddTransient<ISyncedItemRepository, SyncedItemRepository>();
services.AddTransient<IDriveStateRepository, DriveStateRepository>();
services.AddTransient<ISyncRepository, SyncRepository>();
services.AddTransient<IFileClassificationRuleRepository, FileClassificationRuleRepository>();

services.AddTransient<IAccountOnboardingService, AccountOnboardingService>();
services.AddTransient<AddAccountWizardViewModel>();
services.AddTransient<WorkspaceViewModel>();
}

private static void ApplyDatabaseMigrations(IServiceProvider serviceProvider)
{
var dbContextFactory = serviceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
using var startupContext = dbContextFactory.CreateDbContext();
startupContext.Database.Migrate();
}

private static string GetDatabasePath()
{
var configDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var appDir = Path.Combine(configDir, "astar-dev-cloudsync");
Directory.CreateDirectory(appDir);

return Path.Combine(appDir, "sync.db");
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,96 @@
using AStar.Dev.CloudSyncFunctional.Domain;
using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
using AStar.Dev.CloudSyncFunctional.Persistence.Repositories;
using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
using AStar.Dev.FunctionalParadigm;
using Microsoft.Extensions.Logging;

namespace AStar.Dev.CloudSyncFunctional.Onboarding;

/// <inheritdoc />
public sealed partial class AccountOnboardingService(ILogger<AccountOnboardingService> logger) : IAccountOnboardingService
public sealed partial class AccountOnboardingService(IAccountRepository accountRepository, ISyncRuleRepository syncRuleRepository, ILogger<AccountOnboardingService> logger) : IAccountOnboardingService
{
private static readonly char[] InvalidPathChars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|',
..Enumerable.Range(0, 32).Select(i => (char)i)];

/// <inheritdoc />
public Task<Result<OneDriveAccount, PersistenceError>> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default)
public async Task<Result<OneDriveAccount, PersistenceError>> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default)
{
account.IsActive = true;
LogOnboardingComplete(logger, account.AccountId);
var entity = MapToEntity(account);

return await accountRepository.UpsertAsync(entity, ct)
.BindAsync(_ => UpsertSyncRulesAsync(account, ct))
.MatchAsync<Unit, PersistenceError, Result<OneDriveAccount, PersistenceError>>(
_ =>
{
LogOnboardingComplete(logger, account.AccountId);
return new Ok<OneDriveAccount, PersistenceError>(account);
},
error =>
{
LogOnboardingFailed(logger, account.AccountId, error.Message);
return new Fail<OneDriveAccount, PersistenceError>(error);
});
}

private async Task<Result<Unit, PersistenceError>> UpsertSyncRulesAsync(OneDriveAccount account, CancellationToken ct)
{
foreach (var folderId in account.SelectedFolderIds)
{
var rule = new SyncRuleEntity
{
Id = new SyncRuleId(Guid.NewGuid().ToString()),
AccountId = new AccountId(account.AccountId),
RemotePath = folderId,
RuleType = RuleType.Include
};

var stopResult = await syncRuleRepository.UpsertAsync(rule, ct)
.MatchAsync<Unit, PersistenceError, Result<Unit, PersistenceError>?>(
_ => null,
error => new Fail<Unit, PersistenceError>(error))
.ConfigureAwait(false);

if (stopResult is not null)
return stopResult;
}

return Task.FromResult<Result<OneDriveAccount, PersistenceError>>(new Ok<OneDriveAccount, PersistenceError>(account));
return new Ok<Unit, PersistenceError>(Unit.Default);
}

private static AccountEntity MapToEntity(OneDriveAccount account) =>
new()
{
Id = new AccountId(account.AccountId),
Profile = new AccountProfileEntity
{
DisplayName = new DisplayName(account.Profile.DisplayName),
Email = new EmailAddress(account.Profile.Email)
},
IsActive = account.IsActive,
DriveId = new DriveId(account.DriveId ?? string.Empty),
SyncConfig = new AccountSyncConfig
{
LocalSyncPath = new LocalSyncPath(ComputeDefaultSyncPath(account.Profile.Email)),
WorkerCount = 8
}
};

private static string ComputeDefaultSyncPath(string email)
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var sanitised = SanitiseEmail(email);

return Path.Combine(home, "OneDrive", sanitised);
}

private static string SanitiseEmail(string email) =>
string.Concat(email.Where(c => !InvalidPathChars.Contains(c)));

[LoggerMessage(Level = LogLevel.Information, Message = "Account onboarding completed for {AccountId}")]
private static partial void LogOnboardingComplete(ILogger logger, string accountId);

[LoggerMessage(Level = LogLevel.Error, Message = "Account onboarding failed for {AccountId}: {ErrorMessage}")]
private static partial void LogOnboardingFailed(ILogger logger, string accountId, string errorMessage);
}
38 changes: 38 additions & 0 deletions src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,41 @@ public sealed record PersistenceUnexpectedError : PersistenceError
/// <param name="message">The error message.</param>
public PersistenceUnexpectedError(string message) => Message = message;
}

/// <summary>A concurrency conflict occurred — the record was modified by another operation.</summary>
public sealed record ConcurrencyConflictError : PersistenceError
{
/// <inheritdoc/>
public override string Message => "A concurrency conflict occurred. The record was modified by another operation.";
}

/// <summary>A database constraint was violated during a persistence operation.</summary>
public sealed record ConstraintViolationError : PersistenceError
{
/// <inheritdoc/>
public override string Message { get; }

/// <summary>Initialises a new <see cref="ConstraintViolationError"/> with the given detail.</summary>
/// <param name="detail">A description of the constraint that was violated.</param>
public ConstraintViolationError(string detail) => Message = detail;
}

/// <summary>Static factory for constructing <see cref="PersistenceError"/> instances.</summary>
public static class PersistenceErrorFactory
{
/// <summary>Creates a <see cref="ConcurrencyConflictError"/>.</summary>
/// <returns>A new concurrency conflict error.</returns>
public static PersistenceError ConcurrencyConflict() => new ConcurrencyConflictError();

/// <summary>Creates a <see cref="ConstraintViolationError"/>.</summary>
/// <param name="detail">Optional detail describing the violated constraint.</param>
/// <returns>A new constraint violation error.</returns>
public static PersistenceError ConstraintViolation(string? detail) =>
new ConstraintViolationError(string.IsNullOrWhiteSpace(detail) ? "A constraint violation occurred: unknown error." : detail);

/// <summary>Creates a <see cref="PersistenceUnexpectedError"/>.</summary>
/// <param name="message">Optional message describing the unexpected error.</param>
/// <returns>A new unexpected persistence error.</returns>
public static PersistenceError Unexpected(string? message) =>
new PersistenceUnexpectedError(string.IsNullOrWhiteSpace(message) ? "An unexpected persistence error occurred: unknown error." : message);
}
36 changes: 36 additions & 0 deletions src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
using Microsoft.EntityFrameworkCore;

namespace AStar.Dev.CloudSyncFunctional.Persistence;

/// <summary>EF Core database context for the cloud sync application.</summary>
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
/// <summary>Gets the accounts dataset.</summary>
public DbSet<AccountEntity> Accounts => Set<AccountEntity>();

/// <summary>Gets the sync rules dataset.</summary>
public DbSet<SyncRuleEntity> SyncRules => Set<SyncRuleEntity>();

/// <summary>Gets the synced items dataset.</summary>
public DbSet<SyncedItemEntity> SyncedItems => Set<SyncedItemEntity>();

/// <summary>Gets the synced item classifications dataset.</summary>
public DbSet<SyncedItemClassificationEntity> SyncedItemClassifications => Set<SyncedItemClassificationEntity>();

/// <summary>Gets the file classification rules dataset.</summary>
public DbSet<FileClassificationRuleEntity> FileClassificationRules => Set<FileClassificationRuleEntity>();

/// <summary>Gets the sync jobs dataset.</summary>
public DbSet<SyncJobEntity> SyncJobs => Set<SyncJobEntity>();

/// <summary>Gets the sync conflicts dataset.</summary>
public DbSet<SyncConflictEntity> SyncConflicts => Set<SyncConflictEntity>();

/// <summary>Gets the drive states dataset.</summary>
public DbSet<DriveStateEntity> DriveStates => Set<DriveStateEntity>();

/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace AStar.Dev.CloudSyncFunctional.Persistence;

/// <summary>Design-time factory used by EF Core tooling to create <see cref="AppDbContext"/> instances.</summary>
public sealed class AppDbContextDesignTimeFactory : IDesignTimeDbContextFactory<AppDbContext>
{
/// <inheritdoc/>
public AppDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("DataSource=design-time.db")
.Options;

return new AppDbContext(options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;

/// <summary>EF Core entity configuration for <see cref="AccountEntity"/>.</summary>
public sealed class AccountEntityConfiguration : IEntityTypeConfiguration<AccountEntity>
{
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<AccountEntity> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.AccountIdConverter);
builder.Property(e => e.DriveId).HasConversion(SqliteTypeConverters.DriveIdConverter);
builder.Property(e => e.LastSyncedAt)
.HasConversion(SqliteTypeConverters.NullableDateTimeOffsetToTicks)
.IsRequired(false);
builder.ComplexProperty(e => e.Profile, p =>
{
p.Property(prof => prof.DisplayName).HasConversion(SqliteTypeConverters.DisplayNameConverter).HasColumnName("DisplayName");
p.Property(prof => prof.Email).HasConversion(SqliteTypeConverters.EmailAddressConverter).HasColumnName("Email");
});
builder.ComplexProperty(e => e.SyncConfig, sc =>
{
sc.Property(c => c.LocalSyncPath).HasConversion(SqliteTypeConverters.LocalSyncPathConverter).HasColumnName("LocalSyncPath");
sc.Property(c => c.WorkerCount).HasColumnName("WorkerCount");
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;

/// <summary>EF Core entity configuration for <see cref="DriveStateEntity"/>.</summary>
public sealed class DriveStateEntityConfiguration : IEntityTypeConfiguration<DriveStateEntity>
{
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<DriveStateEntity> builder)
{
builder.HasKey(e => e.AccountId);
builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter);
builder.Property(e => e.LastCheckedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks);
builder.HasOne<AccountEntity>()
.WithMany()
.HasForeignKey(e => e.AccountId)
.OnDelete(DeleteBehavior.Cascade);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;

/// <summary>EF Core entity configuration for <see cref="FileClassificationRuleEntity"/>.</summary>
public sealed class FileClassificationRuleEntityConfiguration : IEntityTypeConfiguration<FileClassificationRuleEntity>
{
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<FileClassificationRuleEntity> builder)
=> builder.HasKey(e => e.Id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;

/// <summary>EF Core entity configuration for <see cref="SyncConflictEntity"/>.</summary>
public sealed class SyncConflictEntityConfiguration : IEntityTypeConfiguration<SyncConflictEntity>
{
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<SyncConflictEntity> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncConflictIdConverter);
builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter);
builder.Property(e => e.LocalModifiedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks);
builder.Property(e => e.RemoteModifiedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks);
builder.HasOne<AccountEntity>()
.WithMany()
.HasForeignKey(e => e.AccountId)
.OnDelete(DeleteBehavior.Cascade);
}
}
Loading
Loading