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
2 changes: 2 additions & 0 deletions src/AStar.Dev.CloudSyncFunctional/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using AStar.Dev.CloudSyncFunctional.Onboarding;
using AStar.Dev.CloudSyncFunctional.Persistence;
using AStar.Dev.CloudSyncFunctional.Persistence.Repositories;
using AStar.Dev.CloudSyncFunctional.Recovery;
using AStar.Dev.CloudSyncFunctional.Wizard;
using AStar.Dev.CloudSyncFunctional.Workspace;
using Avalonia;
Expand Down Expand Up @@ -83,6 +84,7 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio
services.AddTransient<IFileClassificationRuleRepository, FileClassificationRuleRepository>();

services.AddTransient<IAccountOnboardingService, AccountOnboardingService>();
services.AddTransient<ISyncRecoveryService, SyncRecoveryService>();
services.AddTransient<AddAccountWizardViewModel>();
services.AddTransient<WorkspaceViewModel>();
}
Expand Down
18 changes: 9 additions & 9 deletions src/AStar.Dev.CloudSyncFunctional/Auth/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,25 @@ public async Task<Result<AuthResult, AuthError>> SignInInteractiveAsync(Cancella
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);

return new Ok<AuthResult, AuthError>(BuildAuthResult(msalResult));
return BuildAuthResult(msalResult);
}
catch (MsalClientException ex) when (ex.ErrorCode is "authentication_canceled" or "user_canceled")
{
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Cancelled());
return AuthErrorFactory.Cancelled();
}
catch (OperationCanceledException)
{
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Cancelled());
return AuthErrorFactory.Cancelled();
}
catch (MsalException ex)
{
LogAuthFailed(logger, ex.Message);
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed(ex.Message));
return AuthErrorFactory.Failed(ex.Message);
}
catch (Exception ex)
{
LogAuthFailed(logger, ex.Message);
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed(ex.Message));
return AuthErrorFactory.Failed(ex.Message);
}
}

Expand All @@ -52,20 +52,20 @@ public async Task<Result<AuthResult, AuthError>> AcquireTokenSilentAsync(string
var accounts = await app.GetAccountsAsync().ConfigureAwait(false);
var account = accounts.FirstOrDefault(a => a.HomeAccountId?.Identifier == accountId);
if (account is null)
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed("Account not found in token cache."));
return AuthErrorFactory.Failed("Account not found in token cache.");

var msalResult = await app.AcquireTokenSilent(Scopes, account).ExecuteAsync(cancellationToken).ConfigureAwait(false);

return new Ok<AuthResult, AuthError>(BuildAuthResult(msalResult));
return BuildAuthResult(msalResult);
}
catch (MsalUiRequiredException)
{
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed("Re-authentication required."));
return AuthErrorFactory.Failed("Re-authentication required.");
}
catch (Exception ex)
{
LogAuthFailed(logger, ex.Message);
return new Fail<AuthResult, AuthError>(AuthErrorFactory.Failed(ex.Message));
return AuthErrorFactory.Failed(ex.Message);
}
}

Expand Down
5 changes: 2 additions & 3 deletions src/AStar.Dev.CloudSyncFunctional/Graph/GraphClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ public sealed class GraphClientFactory : IGraphClientFactory
public Result<GraphServiceClient, GraphError> CreateClient(string accessToken)
{
if (string.IsNullOrWhiteSpace(accessToken))
return new Fail<GraphServiceClient, GraphError>(GraphErrorFactory.Unexpected("Access token must not be null or whitespace."));
return GraphErrorFactory.Unexpected("Access token must not be null or whitespace.");

return new Ok<GraphServiceClient, GraphError>(
new GraphServiceClient(new BaseBearerTokenAuthenticationProvider(new StaticAccessTokenProvider(accessToken))));
return new GraphServiceClient(new BaseBearerTokenAuthenticationProvider(new StaticAccessTokenProvider(accessToken)));
}

private sealed class StaticAccessTokenProvider(string token) : IAccessTokenProvider
Expand Down
4 changes: 2 additions & 2 deletions src/AStar.Dev.CloudSyncFunctional/Graph/GraphService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ private async Task<Result<T, GraphError>> ExecuteGraphOperationAsync<T>(string a
{
LogGraphFailed(logger, accountId, ex.Message);

return new Fail<T, GraphError>(GraphErrorFactory.Unexpected(ex.Message));
return GraphErrorFactory.Unexpected(ex.Message);
}
}

Expand Down Expand Up @@ -59,7 +59,7 @@ private static Task<Result<List<DriveFolder>, GraphError>> GetFoldersFromPagesAs
var folders = (foldersSoFar ?? []).Concat(GetFoldersFromPage(page)).ToList();

return page.OdataNextLink is null
? Task.FromResult<Result<List<DriveFolder>, GraphError>>(new Ok<List<DriveFolder>, GraphError>(folders))
? Task.FromResult<Result<List<DriveFolder>, GraphError>>(folders)
: GetFolderPageAsync(client, driveFound, rootFound, page.OdataNextLink, cancellationToken)
.BindAsync(nextPage => GetFoldersFromPagesAsync(client, driveFound, rootFound, nextPage, folders, cancellationToken));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,18 @@ public async Task<Result<OneDriveAccount, PersistenceError>> CompleteOnboardingA
_ =>
{
LogOnboardingComplete(logger, account.AccountId.Value);
return new Ok<OneDriveAccount, PersistenceError>(account);
return account;
},
error =>
{
LogOnboardingFailed(logger, account.AccountId.Value, error.Message);
return new Fail<OneDriveAccount, PersistenceError>(error);
return error;
});
}

private Task<Result<Unit, PersistenceError>> UpsertSyncRulesAsync(OneDriveAccount account, CancellationToken cancellationToken) =>
account.SelectedFolders.Aggregate(
Task.FromResult<Result<Unit, PersistenceError>>(new Ok<Unit, PersistenceError>(Unit.Default)),
Task.FromResult<Result<Unit, PersistenceError>>(Unit.Default),
(current, folder) => current.BindAsync(_ => syncRuleRepository.UpsertAsync(CreateSyncRule(account, folder), cancellationToken)));

private static SyncRuleEntity CreateSyncRule(OneDriveAccount account, SelectedFolder folder) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ public async Task<Result<Unit, PersistenceError>> UpsertAsync(AccountEntity enti
context.Entry(existing).CurrentValues.SetValues(entity);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.ConcurrencyConflict());
return PersistenceErrorFactory.ConcurrencyConflict();
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}

Expand All @@ -66,11 +66,11 @@ public async Task<Result<Unit, PersistenceError>> DeleteAsync(AccountId id, Canc
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ public async Task<Result<Unit, PersistenceError>> UpsertAsync(DriveStateEntity e
context.Entry(existing).CurrentValues.SetValues(entity);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.ConcurrencyConflict());
return PersistenceErrorFactory.ConcurrencyConflict();
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ public async Task<Result<Unit, PersistenceError>> UpsertAsync(FileClassification
context.Entry(existing).CurrentValues.SetValues(entity);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.ConcurrencyConflict());
return PersistenceErrorFactory.ConcurrencyConflict();
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}

Expand All @@ -54,11 +54,11 @@ public async Task<Result<Unit, PersistenceError>> DeleteAsync(string id, Cancell
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,43 @@ public interface ISyncRepository
{
/// <summary>Retrieves all pending conflicts for a given account.</summary>
/// <param name="accountId">The account identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>All pending conflicts for the account.</returns>
Task<IReadOnlyList<SyncConflictEntity>> GetPendingConflictsAsync(AccountId accountId, CancellationToken cancellationToken = default);

/// <summary>Upserts a sync conflict.</summary>
/// <param name="entity">The conflict to upsert.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ok on success, Fail on error.</returns>
Task<Result<Unit, PersistenceError>> UpsertConflictAsync(SyncConflictEntity entity, CancellationToken cancellationToken = default);

/// <summary>Marks a conflict as resolved.</summary>
/// <param name="id">The conflict identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ok on success, Fail on error.</returns>
Task<Result<Unit, PersistenceError>> ResolveConflictAsync(SyncConflictId id, CancellationToken cancellationToken = default);

/// <summary>Upserts a sync job.</summary>
/// <param name="entity">The job to upsert.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ok on success, Fail on error.</returns>
Task<Result<Unit, PersistenceError>> UpsertJobAsync(SyncJobEntity entity, CancellationToken cancellationToken = default);

/// <summary>Removes all completed jobs for a given account.</summary>
/// <param name="accountId">The account identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ok on success, Fail on error.</returns>
Task<Result<Unit, PersistenceError>> ClearCompletedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default);

/// <summary>Retrieves all jobs in "Running" state for a given account (crash survivors).</summary>
/// <param name="accountId">The account identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Jobs with Status == "Running".</returns>
Task<IReadOnlyList<SyncJobEntity>> GetInterruptedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default);

/// <summary>Resets all "Running" jobs for an account to "Interrupted" status.</summary>
/// <param name="accountId">The account identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Ok on success, Fail on error.</returns>
Task<Result<Unit, PersistenceError>> ResetInterruptedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public sealed class SyncRepository(IDbContextFactory<AppDbContext> dbFactory) :
private const string PendingState = "Pending";
private const string ResolvedState = "Resolved";
private const string CompletedStatus = "Completed";
private const string RunningStatus = "Running";
private const string InterruptedStatus = "Interrupted";

/// <inheritdoc/>
public async Task<IReadOnlyList<SyncConflictEntity>> GetPendingConflictsAsync(AccountId accountId, CancellationToken cancellationToken = default)
Expand All @@ -38,15 +40,15 @@ public async Task<Result<Unit, PersistenceError>> UpsertConflictAsync(SyncConfli
context.Entry(existing).CurrentValues.SetValues(entity);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.ConcurrencyConflict());
return PersistenceErrorFactory.ConcurrencyConflict();
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}

Expand All @@ -57,21 +59,20 @@ public async Task<Result<Unit, PersistenceError>> ResolveConflictAsync(SyncConfl
{
await using var context = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var existing = await context.SyncConflicts.FindAsync([id], cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
existing.State = ResolvedState;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
if (existing is null) return Unit.Default;

existing.State = ResolvedState;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.ConcurrencyConflict());
return PersistenceErrorFactory.ConcurrencyConflict();
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}

Expand All @@ -88,15 +89,15 @@ public async Task<Result<Unit, PersistenceError>> UpsertJobAsync(SyncJobEntity e
context.Entry(existing).CurrentValues.SetValues(entity);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.ConcurrencyConflict());
return PersistenceErrorFactory.ConcurrencyConflict();
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}

Expand All @@ -113,11 +114,48 @@ public async Task<Result<Unit, PersistenceError>> ClearCompletedJobsAsync(Accoun
context.SyncJobs.RemoveRange(completed);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

return new Ok<Unit, PersistenceError>(Unit.Default);
return Unit.Default;
}
catch (DbUpdateException ex)
{
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}

/// <inheritdoc/>
public async Task<IReadOnlyList<SyncJobEntity>> GetInterruptedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default)
{
await using var context = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);

return await context.SyncJobs
.AsNoTracking()
.Where(j => j.AccountId == accountId && j.Status == RunningStatus)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}

/// <inheritdoc/>
public async Task<Result<Unit, PersistenceError>> ResetInterruptedJobsAsync(AccountId accountId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var running = await context.SyncJobs
.Where(j => j.AccountId == accountId && j.Status == RunningStatus)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
running.ForEach(job => job.Status = InterruptedStatus);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

return Unit.Default;
}
catch (DbUpdateConcurrencyException)
{
return PersistenceErrorFactory.ConcurrencyConflict();
}
catch (DbUpdateException ex)
{
return new Fail<Unit, PersistenceError>(PersistenceErrorFactory.Unexpected(ex.Message));
return PersistenceErrorFactory.Unexpected(ex.Message);
}
}
}
Loading
Loading