diff --git a/AStar.Dev.CloudSync.slnx b/AStar.Dev.CloudSync.slnx
index ddee98d..f6cd9d9 100644
--- a/AStar.Dev.CloudSync.slnx
+++ b/AStar.Dev.CloudSync.slnx
@@ -6,5 +6,6 @@
+
diff --git a/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj b/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj
index fac43a9..03230c3 100644
--- a/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj
+++ b/src/AStar.Dev.CloudSyncFunctional/AStar.Dev.CloudSyncFunctional.csproj
@@ -6,7 +6,7 @@
enable
app.manifest
true
- NU1903
+ NU1903;CS9113
astar-dev-cloudsync-functional
@@ -33,6 +33,11 @@
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs
index 204757c..1fd4893 100644
--- a/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs
+++ b/src/AStar.Dev.CloudSyncFunctional/App.axaml.cs
@@ -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;
@@ -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());
@@ -58,8 +63,36 @@ private static void ConfigureServices(IServiceCollection services, IConfiguratio
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
+
+ var connectionString = $"DataSource={GetDatabasePath()}";
+ services.AddDbContextFactory(options =>
+ options.UseSqlite(connectionString), ServiceLifetime.Singleton);
+
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
}
+
+ private static void ApplyDatabaseMigrations(IServiceProvider serviceProvider)
+ {
+ var dbContextFactory = serviceProvider.GetRequiredService>();
+ 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");
+ }
}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs
index 06a543d..bd9d684 100644
--- a/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs
+++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/AccountOnboardingService.cs
@@ -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;
///
-public sealed partial class AccountOnboardingService(ILogger logger) : IAccountOnboardingService
+public sealed partial class AccountOnboardingService(IAccountRepository accountRepository, ISyncRuleRepository syncRuleRepository, ILogger logger) : IAccountOnboardingService
{
+ private static readonly char[] InvalidPathChars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|',
+ ..Enumerable.Range(0, 32).Select(i => (char)i)];
+
///
- public Task> CompleteOnboardingAsync(OneDriveAccount account, CancellationToken ct = default)
+ public async Task> 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>(
+ _ =>
+ {
+ LogOnboardingComplete(logger, account.AccountId);
+ return new Ok(account);
+ },
+ error =>
+ {
+ LogOnboardingFailed(logger, account.AccountId, error.Message);
+ return new Fail(error);
+ });
+ }
+
+ private async Task> 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?>(
+ _ => null,
+ error => new Fail(error))
+ .ConfigureAwait(false);
+
+ if (stopResult is not null)
+ return stopResult;
+ }
- return Task.FromResult>(new Ok(account));
+ return new Ok(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);
}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs b/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs
index 057cd7a..6d088f9 100644
--- a/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs
+++ b/src/AStar.Dev.CloudSyncFunctional/Onboarding/PersistenceError.cs
@@ -17,3 +17,41 @@ public sealed record PersistenceUnexpectedError : PersistenceError
/// The error message.
public PersistenceUnexpectedError(string message) => Message = message;
}
+
+/// A concurrency conflict occurred — the record was modified by another operation.
+public sealed record ConcurrencyConflictError : PersistenceError
+{
+ ///
+ public override string Message => "A concurrency conflict occurred. The record was modified by another operation.";
+}
+
+/// A database constraint was violated during a persistence operation.
+public sealed record ConstraintViolationError : PersistenceError
+{
+ ///
+ public override string Message { get; }
+
+ /// Initialises a new with the given detail.
+ /// A description of the constraint that was violated.
+ public ConstraintViolationError(string detail) => Message = detail;
+}
+
+/// Static factory for constructing instances.
+public static class PersistenceErrorFactory
+{
+ /// Creates a .
+ /// A new concurrency conflict error.
+ public static PersistenceError ConcurrencyConflict() => new ConcurrencyConflictError();
+
+ /// Creates a .
+ /// Optional detail describing the violated constraint.
+ /// A new constraint violation error.
+ public static PersistenceError ConstraintViolation(string? detail) =>
+ new ConstraintViolationError(string.IsNullOrWhiteSpace(detail) ? "A constraint violation occurred: unknown error." : detail);
+
+ /// Creates a .
+ /// Optional message describing the unexpected error.
+ /// A new unexpected persistence error.
+ public static PersistenceError Unexpected(string? message) =>
+ new PersistenceUnexpectedError(string.IsNullOrWhiteSpace(message) ? "An unexpected persistence error occurred: unknown error." : message);
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContext.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContext.cs
new file mode 100644
index 0000000..995978c
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContext.cs
@@ -0,0 +1,36 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence;
+
+/// EF Core database context for the cloud sync application.
+public sealed class AppDbContext(DbContextOptions options) : DbContext(options)
+{
+ /// Gets the accounts dataset.
+ public DbSet Accounts => Set();
+
+ /// Gets the sync rules dataset.
+ public DbSet SyncRules => Set();
+
+ /// Gets the synced items dataset.
+ public DbSet SyncedItems => Set();
+
+ /// Gets the synced item classifications dataset.
+ public DbSet SyncedItemClassifications => Set();
+
+ /// Gets the file classification rules dataset.
+ public DbSet FileClassificationRules => Set();
+
+ /// Gets the sync jobs dataset.
+ public DbSet SyncJobs => Set();
+
+ /// Gets the sync conflicts dataset.
+ public DbSet SyncConflicts => Set();
+
+ /// Gets the drive states dataset.
+ public DbSet DriveStates => Set();
+
+ ///
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ => modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContextDesignTimeFactory.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContextDesignTimeFactory.cs
new file mode 100644
index 0000000..3ccc6f5
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/AppDbContextDesignTimeFactory.cs
@@ -0,0 +1,18 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence;
+
+/// Design-time factory used by EF Core tooling to create instances.
+public sealed class AppDbContextDesignTimeFactory : IDesignTimeDbContextFactory
+{
+ ///
+ public AppDbContext CreateDbContext(string[] args)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite("DataSource=design-time.db")
+ .Options;
+
+ return new AppDbContext(options);
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs
new file mode 100644
index 0000000..74295ec
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/AccountEntityConfiguration.cs
@@ -0,0 +1,30 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;
+
+/// EF Core entity configuration for .
+public sealed class AccountEntityConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder 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");
+ });
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs
new file mode 100644
index 0000000..393f804
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/DriveStateEntityConfiguration.cs
@@ -0,0 +1,21 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;
+
+/// EF Core entity configuration for .
+public sealed class DriveStateEntityConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(e => e.AccountId);
+ builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter);
+ builder.Property(e => e.LastCheckedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.AccountId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/FileClassificationRuleEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/FileClassificationRuleEntityConfiguration.cs
new file mode 100644
index 0000000..b73a91e
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/FileClassificationRuleEntityConfiguration.cs
@@ -0,0 +1,13 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;
+
+/// EF Core entity configuration for .
+public sealed class FileClassificationRuleEntityConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ => builder.HasKey(e => e.Id);
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs
new file mode 100644
index 0000000..0a6039b
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncConflictEntityConfiguration.cs
@@ -0,0 +1,23 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;
+
+/// EF Core entity configuration for .
+public sealed class SyncConflictEntityConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder 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()
+ .WithMany()
+ .HasForeignKey(e => e.AccountId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs
new file mode 100644
index 0000000..936d3f9
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncJobEntityConfiguration.cs
@@ -0,0 +1,22 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;
+
+/// EF Core entity configuration for .
+public sealed class SyncJobEntityConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncJobIdConverter);
+ builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter);
+ builder.Property(e => e.CreatedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.AccountId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs
new file mode 100644
index 0000000..0c4c579
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncRuleEntityConfiguration.cs
@@ -0,0 +1,21 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;
+
+/// EF Core entity configuration for .
+public sealed class SyncRuleEntityConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncRuleIdConverter);
+ builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.AccountId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs
new file mode 100644
index 0000000..2b544ae
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemClassificationEntityConfiguration.cs
@@ -0,0 +1,20 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;
+
+/// EF Core entity configuration for .
+public sealed class SyncedItemClassificationEntityConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.SyncedItemId).HasConversion(SqliteTypeConverters.SyncedItemIdConverter);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.SyncedItemId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs
new file mode 100644
index 0000000..3b59ef9
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Configuration/SyncedItemEntityConfiguration.cs
@@ -0,0 +1,22 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Configuration;
+
+/// EF Core entity configuration for .
+public sealed class SyncedItemEntityConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasConversion(SqliteTypeConverters.SyncedItemIdConverter);
+ builder.Property(e => e.AccountId).HasConversion(SqliteTypeConverters.AccountIdConverter);
+ builder.Property(e => e.RemoteModifiedAt).HasConversion(SqliteTypeConverters.DateTimeOffsetToTicks);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.AccountId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountEntity.cs
new file mode 100644
index 0000000..c16bbee
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountEntity.cs
@@ -0,0 +1,25 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// EF Core persistence entity for an OneDrive account.
+public sealed class AccountEntity
+{
+ /// Gets or sets the MSAL HomeAccountId identifier.
+ public AccountId Id { get; set; }
+
+ /// Gets or sets the account profile (display name, email).
+ public AccountProfileEntity Profile { get; set; } = new();
+
+ /// Gets or sets whether this account is active for sync.
+ public bool IsActive { get; set; }
+
+ /// Gets or sets the Graph drive ID.
+ public DriveId DriveId { get; set; }
+
+ /// Gets or sets the sync configuration.
+ public AccountSyncConfig SyncConfig { get; set; } = new();
+
+ /// Gets or sets the UTC timestamp of the last successful sync.
+ public DateTimeOffset? LastSyncedAt { get; set; }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountProfileEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountProfileEntity.cs
new file mode 100644
index 0000000..4a51141
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountProfileEntity.cs
@@ -0,0 +1,13 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// Profile information stored as a complex property on .
+public sealed class AccountProfileEntity
+{
+ /// Gets or sets the display name.
+ public DisplayName DisplayName { get; set; }
+
+ /// Gets or sets the email address.
+ public EmailAddress Email { get; set; }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountSyncConfig.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountSyncConfig.cs
new file mode 100644
index 0000000..85d6f1b
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/AccountSyncConfig.cs
@@ -0,0 +1,13 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// Sync configuration stored as a complex property on .
+public sealed class AccountSyncConfig
+{
+ /// Gets or sets the local folder where files are synced.
+ public LocalSyncPath LocalSyncPath { get; set; }
+
+ /// Gets or sets the number of parallel sync workers (1–10).
+ public int WorkerCount { get; set; } = 8;
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/DriveStateEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/DriveStateEntity.cs
new file mode 100644
index 0000000..19485bf
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/DriveStateEntity.cs
@@ -0,0 +1,16 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// EF Core persistence entity for the cached drive state of an account.
+public sealed class DriveStateEntity
+{
+ /// Gets or sets the account identifier (primary key and foreign key).
+ public AccountId AccountId { get; set; }
+
+ /// Gets or sets the delta link for incremental change enumeration.
+ public string DeltaLink { get; set; } = string.Empty;
+
+ /// Gets or sets the UTC timestamp of the last delta check.
+ public DateTimeOffset LastCheckedAt { get; set; }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/FileClassificationRuleEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/FileClassificationRuleEntity.cs
new file mode 100644
index 0000000..f063d97
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/FileClassificationRuleEntity.cs
@@ -0,0 +1,17 @@
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// EF Core persistence entity for a file classification rule.
+public sealed class FileClassificationRuleEntity
+{
+ /// Gets or sets the rule identifier.
+ public string Id { get; set; } = string.Empty;
+
+ /// Gets or sets the human-readable name for this rule.
+ public string Name { get; set; } = string.Empty;
+
+ /// Gets or sets the classification label applied when this rule matches.
+ public string Classification { get; set; } = string.Empty;
+
+ /// Gets or sets the keywords that trigger this rule, stored as a comma-delimited string.
+ public string Keywords { get; set; } = string.Empty;
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/RuleType.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/RuleType.cs
new file mode 100644
index 0000000..e1cdabc
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/RuleType.cs
@@ -0,0 +1,11 @@
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// Indicates whether a sync rule includes or excludes a path.
+public enum RuleType
+{
+ /// The path is included in sync.
+ Include,
+
+ /// The path is excluded from sync.
+ Exclude
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncConflictEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncConflictEntity.cs
new file mode 100644
index 0000000..ac72a82
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncConflictEntity.cs
@@ -0,0 +1,25 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// EF Core persistence entity for a detected sync conflict.
+public sealed class SyncConflictEntity
+{
+ /// Gets or sets the sync conflict identifier.
+ public SyncConflictId Id { get; set; }
+
+ /// Gets or sets the account this conflict belongs to.
+ public AccountId AccountId { get; set; }
+
+ /// Gets or sets the remote OneDrive item identifier involved in the conflict.
+ public string RemoteItemId { get; set; } = string.Empty;
+
+ /// Gets or sets the UTC timestamp of the local file at the time of conflict detection.
+ public DateTimeOffset LocalModifiedAt { get; set; }
+
+ /// Gets or sets the UTC timestamp of the remote file at the time of conflict detection.
+ public DateTimeOffset RemoteModifiedAt { get; set; }
+
+ /// Gets or sets the resolution state — "Pending" or "Resolved".
+ public string State { get; set; } = "Pending";
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncJobEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncJobEntity.cs
new file mode 100644
index 0000000..d94cfd1
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncJobEntity.cs
@@ -0,0 +1,28 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// EF Core persistence entity for a pending or completed sync job.
+public sealed class SyncJobEntity
+{
+ /// Gets or sets the sync job identifier.
+ public SyncJobId Id { get; set; }
+
+ /// Gets or sets the account this job belongs to.
+ public AccountId AccountId { get; set; }
+
+ /// Gets or sets the remote OneDrive path involved in this job.
+ public string RemotePath { get; set; } = string.Empty;
+
+ /// Gets or sets the local file system path involved in this job.
+ public string LocalPath { get; set; } = string.Empty;
+
+ /// Gets or sets the type of job — "Download" or "Upload".
+ public string JobType { get; set; } = string.Empty;
+
+ /// Gets or sets the current status — "Pending", "Running", "Completed", or "Failed".
+ public string Status { get; set; } = string.Empty;
+
+ /// Gets or sets the UTC timestamp when this job was created.
+ public DateTimeOffset CreatedAt { get; set; }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncRuleEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncRuleEntity.cs
new file mode 100644
index 0000000..89f7197
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncRuleEntity.cs
@@ -0,0 +1,19 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// EF Core persistence entity for a sync rule.
+public sealed class SyncRuleEntity
+{
+ /// Gets or sets the sync rule identifier.
+ public SyncRuleId Id { get; set; }
+
+ /// Gets or sets the account this rule belongs to.
+ public AccountId AccountId { get; set; }
+
+ /// Gets or sets the remote OneDrive path this rule applies to.
+ public string RemotePath { get; set; } = string.Empty;
+
+ /// Gets or sets whether this rule includes or excludes the path.
+ public RuleType RuleType { get; set; }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemClassificationEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemClassificationEntity.cs
new file mode 100644
index 0000000..a37bb36
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemClassificationEntity.cs
@@ -0,0 +1,16 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// EF Core persistence entity for a classification applied to a synced item.
+public sealed class SyncedItemClassificationEntity
+{
+ /// Gets or sets the identifier of the classification record.
+ public string Id { get; set; } = string.Empty;
+
+ /// Gets or sets the synced item this classification belongs to.
+ public SyncedItemId SyncedItemId { get; set; }
+
+ /// Gets or sets the classification label.
+ public string Classification { get; set; } = string.Empty;
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemEntity.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemEntity.cs
new file mode 100644
index 0000000..eb28d13
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Entities/SyncedItemEntity.cs
@@ -0,0 +1,28 @@
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+
+/// EF Core persistence entity for a synced file or folder item.
+public sealed class SyncedItemEntity
+{
+ /// Gets or sets the synced item identifier.
+ public SyncedItemId Id { get; set; }
+
+ /// Gets or sets the account this item belongs to.
+ public AccountId AccountId { get; set; }
+
+ /// Gets or sets the remote OneDrive path of this item.
+ public string RemotePath { get; set; } = string.Empty;
+
+ /// Gets or sets the local file system path of this item.
+ public string LocalPath { get; set; } = string.Empty;
+
+ /// Gets or sets the UTC timestamp of the last known remote modification.
+ public DateTimeOffset RemoteModifiedAt { get; set; }
+
+ /// Gets or sets the remote eTag for conflict detection.
+ public string? ETag { get; set; }
+
+ /// Gets or sets whether this item is a folder.
+ public bool IsFolder { get; set; }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.Designer.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.Designer.cs
new file mode 100644
index 0000000..e367598
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.Designer.cs
@@ -0,0 +1,308 @@
+//
+using System.Collections.Generic;
+using AStar.Dev.CloudSyncFunctional.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260527085721_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("DriveId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("INTEGER");
+
+ b.ComplexProperty(typeof(Dictionary), "Profile", "AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity.Profile#AccountProfileEntity", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("DisplayName");
+
+ b1.Property("Email")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("Email");
+ });
+
+ b.ComplexProperty(typeof(Dictionary), "SyncConfig", "AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity.SyncConfig#AccountSyncConfig", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("LocalSyncPath")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("LocalSyncPath");
+
+ b1.Property("WorkerCount")
+ .HasColumnType("INTEGER")
+ .HasColumnName("WorkerCount");
+ });
+
+ b.HasKey("Id");
+
+ b.ToTable("Accounts");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.DriveStateEntity", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("TEXT");
+
+ b.Property("DeltaLink")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("LastCheckedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("AccountId");
+
+ b.ToTable("DriveStates");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.FileClassificationRuleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Classification")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Keywords")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("FileClassificationRules");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncConflictEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("LocalModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RemoteItemId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemoteModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("State")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("SyncConflicts");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncJobEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("JobType")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("LocalPath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemotePath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("SyncJobs");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncRuleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemotePath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RuleType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("SyncRules");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemClassificationEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Classification")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("SyncedItemId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SyncedItemId");
+
+ b.ToTable("SyncedItemClassifications");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ETag")
+ .HasColumnType("TEXT");
+
+ b.Property("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property("LocalPath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemoteModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RemotePath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("SyncedItems");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.DriveStateEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncConflictEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncJobEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncRuleEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemClassificationEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", null)
+ .WithMany()
+ .HasForeignKey("SyncedItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.cs
new file mode 100644
index 0000000..f4795ff
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/20260527085721_InitialCreate.cs
@@ -0,0 +1,225 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Accounts",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ IsActive = table.Column(type: "INTEGER", nullable: false),
+ DriveId = table.Column(type: "TEXT", nullable: false),
+ LastSyncedAt = table.Column(type: "INTEGER", nullable: true),
+ DisplayName = table.Column(type: "TEXT", nullable: false),
+ Email = table.Column(type: "TEXT", nullable: false),
+ LocalSyncPath = table.Column(type: "TEXT", nullable: false),
+ WorkerCount = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Accounts", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "FileClassificationRules",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ Name = table.Column(type: "TEXT", nullable: false),
+ Classification = table.Column(type: "TEXT", nullable: false),
+ Keywords = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_FileClassificationRules", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "DriveStates",
+ columns: table => new
+ {
+ AccountId = table.Column(type: "TEXT", nullable: false),
+ DeltaLink = table.Column(type: "TEXT", nullable: false),
+ LastCheckedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DriveStates", x => x.AccountId);
+ table.ForeignKey(
+ name: "FK_DriveStates_Accounts_AccountId",
+ column: x => x.AccountId,
+ principalTable: "Accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "SyncConflicts",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ AccountId = table.Column(type: "TEXT", nullable: false),
+ RemoteItemId = table.Column(type: "TEXT", nullable: false),
+ LocalModifiedAt = table.Column(type: "INTEGER", nullable: false),
+ RemoteModifiedAt = table.Column(type: "INTEGER", nullable: false),
+ State = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_SyncConflicts", x => x.Id);
+ table.ForeignKey(
+ name: "FK_SyncConflicts_Accounts_AccountId",
+ column: x => x.AccountId,
+ principalTable: "Accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "SyncedItems",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ AccountId = table.Column(type: "TEXT", nullable: false),
+ RemotePath = table.Column(type: "TEXT", nullable: false),
+ LocalPath = table.Column(type: "TEXT", nullable: false),
+ RemoteModifiedAt = table.Column(type: "INTEGER", nullable: false),
+ ETag = table.Column(type: "TEXT", nullable: true),
+ IsFolder = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_SyncedItems", x => x.Id);
+ table.ForeignKey(
+ name: "FK_SyncedItems_Accounts_AccountId",
+ column: x => x.AccountId,
+ principalTable: "Accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "SyncJobs",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ AccountId = table.Column(type: "TEXT", nullable: false),
+ RemotePath = table.Column(type: "TEXT", nullable: false),
+ LocalPath = table.Column(type: "TEXT", nullable: false),
+ JobType = table.Column(type: "TEXT", nullable: false),
+ Status = table.Column(type: "TEXT", nullable: false),
+ CreatedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_SyncJobs", x => x.Id);
+ table.ForeignKey(
+ name: "FK_SyncJobs_Accounts_AccountId",
+ column: x => x.AccountId,
+ principalTable: "Accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "SyncRules",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ AccountId = table.Column(type: "TEXT", nullable: false),
+ RemotePath = table.Column(type: "TEXT", nullable: false),
+ RuleType = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_SyncRules", x => x.Id);
+ table.ForeignKey(
+ name: "FK_SyncRules_Accounts_AccountId",
+ column: x => x.AccountId,
+ principalTable: "Accounts",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "SyncedItemClassifications",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ SyncedItemId = table.Column(type: "TEXT", nullable: false),
+ Classification = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_SyncedItemClassifications", x => x.Id);
+ table.ForeignKey(
+ name: "FK_SyncedItemClassifications_SyncedItems_SyncedItemId",
+ column: x => x.SyncedItemId,
+ principalTable: "SyncedItems",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SyncConflicts_AccountId",
+ table: "SyncConflicts",
+ column: "AccountId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SyncedItemClassifications_SyncedItemId",
+ table: "SyncedItemClassifications",
+ column: "SyncedItemId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SyncedItems_AccountId",
+ table: "SyncedItems",
+ column: "AccountId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SyncJobs_AccountId",
+ table: "SyncJobs",
+ column: "AccountId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SyncRules_AccountId",
+ table: "SyncRules",
+ column: "AccountId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DriveStates");
+
+ migrationBuilder.DropTable(
+ name: "FileClassificationRules");
+
+ migrationBuilder.DropTable(
+ name: "SyncConflicts");
+
+ migrationBuilder.DropTable(
+ name: "SyncedItemClassifications");
+
+ migrationBuilder.DropTable(
+ name: "SyncJobs");
+
+ migrationBuilder.DropTable(
+ name: "SyncRules");
+
+ migrationBuilder.DropTable(
+ name: "SyncedItems");
+
+ migrationBuilder.DropTable(
+ name: "Accounts");
+ }
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..d5304e1
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,305 @@
+//
+using System.Collections.Generic;
+using AStar.Dev.CloudSyncFunctional.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("DriveId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("INTEGER");
+
+ b.ComplexProperty(typeof(Dictionary), "Profile", "AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity.Profile#AccountProfileEntity", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("DisplayName");
+
+ b1.Property("Email")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("Email");
+ });
+
+ b.ComplexProperty(typeof(Dictionary), "SyncConfig", "AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity.SyncConfig#AccountSyncConfig", b1 =>
+ {
+ b1.IsRequired();
+
+ b1.Property("LocalSyncPath")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("LocalSyncPath");
+
+ b1.Property("WorkerCount")
+ .HasColumnType("INTEGER")
+ .HasColumnName("WorkerCount");
+ });
+
+ b.HasKey("Id");
+
+ b.ToTable("Accounts");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.DriveStateEntity", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("TEXT");
+
+ b.Property("DeltaLink")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("LastCheckedAt")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("AccountId");
+
+ b.ToTable("DriveStates");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.FileClassificationRuleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Classification")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Keywords")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("FileClassificationRules");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncConflictEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("LocalModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RemoteItemId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemoteModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("State")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("SyncConflicts");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncJobEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("JobType")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("LocalPath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemotePath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("SyncJobs");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncRuleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemotePath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RuleType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("SyncRules");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemClassificationEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Classification")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("SyncedItemId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SyncedItemId");
+
+ b.ToTable("SyncedItemClassifications");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("AccountId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ETag")
+ .HasColumnType("TEXT");
+
+ b.Property("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property("LocalPath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RemoteModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RemotePath")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.ToTable("SyncedItems");
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.DriveStateEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncConflictEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncJobEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncRuleEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemClassificationEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", null)
+ .WithMany()
+ .HasForeignKey("SyncedItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("AStar.Dev.CloudSyncFunctional.Persistence.Entities.SyncedItemEntity", b =>
+ {
+ b.HasOne("AStar.Dev.CloudSyncFunctional.Persistence.Entities.AccountEntity", null)
+ .WithMany()
+ .HasForeignKey("AccountId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs
new file mode 100644
index 0000000..de3b0f7
--- /dev/null
+++ b/src/AStar.Dev.CloudSyncFunctional/Persistence/Repositories/AccountRepository.cs
@@ -0,0 +1,76 @@
+using AStar.Dev.CloudSyncFunctional.Onboarding;
+using AStar.Dev.CloudSyncFunctional.Persistence.Entities;
+using AStar.Dev.CloudSyncFunctional.Persistence.ValueObjects;
+using AStar.Dev.FunctionalParadigm;
+using Microsoft.EntityFrameworkCore;
+
+namespace AStar.Dev.CloudSyncFunctional.Persistence.Repositories;
+
+///
+public sealed class AccountRepository(IDbContextFactory dbFactory) : IAccountRepository
+{
+ ///
+ public async Task