# TableSyncOperation Migration Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Migrate TableSyncOperation from IStagingTableManager/TableSpec to IBulkMergeHelper/IMergeConfiguration, then remove old staging table code. **Architecture:** Create fluent merge configurations for each synced entity type. Extend BulkMergeHelper with mass insert capability (truncate + bulk copy + index management). Refactor TableSyncOperation to use new components. Remove legacy staging table code. **Tech Stack:** .NET 10, Expression trees, SqlBulkCopy, SQL Server MERGE statements --- ## Task 1: Create IMergeConfiguration Interface **Files:** - Create: `src/JdeScoping.DataSync/Contracts/IMergeConfiguration.cs` **Step 1: Create the interface file** ```csharp using System.Linq.Expressions; namespace JdeScoping.DataSync.Contracts; /// /// Defines merge configuration for an entity type. /// /// The entity type. public interface IMergeConfiguration where T : class { /// /// Gets the destination table name in SQL Server. /// string TableName { get; } /// /// Gets the expression defining columns to match on (primary key). /// Expression> MatchOn { get; } /// /// Gets the expression defining columns to update when matched. /// Null means all non-PK columns. /// Expression>? UpdateColumns { get; } /// /// Gets the condition for when to perform updates. /// Null means always update on match. /// Expression>? UpdateWhen { get; } /// /// Gets the expression defining columns to insert. /// Null means all columns. /// Expression>? InsertColumns { get; } } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Contracts/IMergeConfiguration.cs git commit -m "feat(datasync): add IMergeConfiguration interface for fluent merge config" ``` --- ## Task 2: Create IMergeConfigurationRegistry **Files:** - Create: `src/JdeScoping.DataSync/Contracts/IMergeConfigurationRegistry.cs` - Create: `src/JdeScoping.DataSync/Services/MergeConfigurationRegistry.cs` **Step 1: Create the interface** ```csharp namespace JdeScoping.DataSync.Contracts; /// /// Registry for looking up merge configurations by entity type. /// public interface IMergeConfigurationRegistry { /// /// Gets the merge configuration for the specified entity type. /// /// The entity type. /// The merge configuration. /// Thrown if no configuration is registered. IMergeConfiguration GetConfiguration() where T : class; /// /// Checks if a merge configuration exists for the specified entity type. /// /// The entity type. /// True if configuration exists. bool HasConfiguration() where T : class; } ``` **Step 2: Create the implementation** ```csharp namespace JdeScoping.DataSync.Services; /// /// Registry implementation that resolves configurations from DI. /// internal sealed class MergeConfigurationRegistry : IMergeConfigurationRegistry { private readonly IServiceProvider _serviceProvider; public MergeConfigurationRegistry(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); } public IMergeConfiguration GetConfiguration() where T : class { var config = _serviceProvider.GetService(typeof(IMergeConfiguration)) as IMergeConfiguration; return config ?? throw new InvalidOperationException( $"No merge configuration registered for {typeof(T).Name}. " + $"Register IMergeConfiguration<{typeof(T).Name}> in ServiceCollectionExtensions."); } public bool HasConfiguration() where T : class { return _serviceProvider.GetService(typeof(IMergeConfiguration)) != null; } } ``` **Step 3: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 4: Commit** ```bash git add src/JdeScoping.DataSync/Contracts/IMergeConfigurationRegistry.cs git add src/JdeScoping.DataSync/Services/MergeConfigurationRegistry.cs git commit -m "feat(datasync): add MergeConfigurationRegistry for DI-based config lookup" ``` --- ## Task 3: Add MassInsertResult Model **Files:** - Modify: `src/JdeScoping.DataSync/Models/MergeResult.cs` **Step 1: Add MassInsertResult record** Add to the end of the file: ```csharp /// /// Result of a mass insert operation. /// /// Total rows inserted. /// Total elapsed time. /// Whether indexes were rebuilt (vs just re-enabled). public record MassInsertResult( long TotalRowsInserted, TimeSpan Elapsed, bool IndexesRebuilt); ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Models/MergeResult.cs git commit -m "feat(datasync): add MassInsertResult model" ``` --- ## Task 4: Add MassInsertAsync to IBulkMergeHelper **Files:** - Modify: `src/JdeScoping.DataSync/Contracts/IBulkMergeHelper.cs` **Step 1: Add the method signature** Add after the existing `MergeAsync` method: ```csharp /// /// Performs a mass insert (full table refresh) with optional index management. /// Truncates table, disables non-clustered indexes, bulk copies data, rebuilds indexes. /// /// The entity type. /// The source data to insert. /// The destination SQL table name. /// If true, rebuilds indexes after insert. If false, just re-enables them. /// Number of rows per bulk copy batch. 0 = default (10000). /// Cancellation token. /// Result containing row count and timing. Task MassInsertAsync( IAsyncEnumerable data, string destinationTable, bool rebuildIndexes = true, int batchSize = 0, CancellationToken cancellationToken = default) where T : class; ``` **Step 2: Verify build fails (TDD)** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build fails - BulkMergeHelper doesn't implement MassInsertAsync **Step 3: Commit interface change** ```bash git add src/JdeScoping.DataSync/Contracts/IBulkMergeHelper.cs git commit -m "feat(datasync): add MassInsertAsync to IBulkMergeHelper interface" ``` --- ## Task 5: Implement MassInsertAsync in BulkMergeHelper **Files:** - Modify: `src/JdeScoping.DataSync/Services/BulkMergeHelper.cs` **Step 1: Add the implementation** Add after the existing `MergeAsync` method (around line 172): ```csharp /// public async Task MassInsertAsync( IAsyncEnumerable data, string destinationTable, bool rebuildIndexes = true, int batchSize = 0, CancellationToken cancellationToken = default) where T : class { ArgumentNullException.ThrowIfNull(data); ArgumentException.ThrowIfNullOrWhiteSpace(destinationTable); var stopwatch = Stopwatch.StartNew(); var effectiveBatchSize = batchSize > 0 ? batchSize : DefaultBatchSize; _logger.LogInformation( "Starting mass insert to {DestinationTable}. BatchSize={BatchSize}, RebuildIndexes={RebuildIndexes}", destinationTable, effectiveBatchSize, rebuildIndexes); await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken); long totalRows = 0; try { // Step 1: Disable non-clustered indexes await DisableNonClusteredIndexesAsync(connection, destinationTable, cancellationToken); // Step 2: Truncate the table await connection.ExecuteAsync( new CommandDefinition($"TRUNCATE TABLE [{destinationTable}]", cancellationToken: cancellationToken)); _logger.LogDebug("Truncated table {DestinationTable}", destinationTable); // Step 3: Bulk copy data in batches var batch = new List(effectiveBatchSize); await foreach (var item in data.WithCancellation(cancellationToken)) { batch.Add(item); if (batch.Count >= effectiveBatchSize) { await BulkCopyDirectAsync(connection, batch, destinationTable, cancellationToken); totalRows += batch.Count; _logger.LogDebug("Inserted batch of {Count} rows to {Table}", batch.Count, destinationTable); batch.Clear(); } } // Insert remaining rows if (batch.Count > 0) { await BulkCopyDirectAsync(connection, batch, destinationTable, cancellationToken); totalRows += batch.Count; _logger.LogDebug("Inserted final batch of {Count} rows to {Table}", batch.Count, destinationTable); } // Step 4: Rebuild or re-enable indexes if (rebuildIndexes) { await RebuildIndexesAsync(connection, destinationTable, cancellationToken); } else { await EnableNonClusteredIndexesAsync(connection, destinationTable, cancellationToken); } stopwatch.Stop(); _logger.LogInformation( "Mass insert to {DestinationTable} completed. TotalRows={TotalRows}, Elapsed={Elapsed}ms", destinationTable, totalRows, stopwatch.ElapsedMilliseconds); return new MassInsertResult(totalRows, stopwatch.Elapsed, rebuildIndexes); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Mass insert to {DestinationTable} failed after {TotalRows} rows", destinationTable, totalRows); // Try to re-enable indexes even on failure try { await EnableNonClusteredIndexesAsync(connection, destinationTable, CancellationToken.None); } catch (Exception indexEx) { _logger.LogWarning(indexEx, "Failed to re-enable indexes on {Table} after error", destinationTable); } throw; } } private async Task BulkCopyDirectAsync( SqlConnection connection, IReadOnlyList batch, string destinationTable, CancellationToken cancellationToken) where T : class { using var bulkCopy = new SqlBulkCopy(connection) { DestinationTableName = $"[{destinationTable}]", BatchSize = batch.Count, BulkCopyTimeout = 3600 }; using var reader = _dataReaderFactory.CreateReader(batch.ToAsyncEnumerable()); await bulkCopy.WriteToServerAsync(reader, cancellationToken); } private static async Task DisableNonClusteredIndexesAsync( SqlConnection connection, string tableName, CancellationToken cancellationToken) { var sql = $@" DECLARE @sql NVARCHAR(MAX) = ''; SELECT @sql = @sql + 'ALTER INDEX [' + i.name + '] ON [{tableName}] DISABLE;' + CHAR(13) FROM sys.indexes i INNER JOIN sys.tables t ON i.object_id = t.object_id WHERE t.name = @tableName AND i.type = 2 AND i.is_disabled = 0; EXEC sp_executesql @sql;"; await connection.ExecuteAsync( new CommandDefinition(sql, new { tableName }, commandTimeout: 300, cancellationToken: cancellationToken)); } private static async Task EnableNonClusteredIndexesAsync( SqlConnection connection, string tableName, CancellationToken cancellationToken) { var sql = $@" DECLARE @sql NVARCHAR(MAX) = ''; SELECT @sql = @sql + 'ALTER INDEX [' + i.name + '] ON [{tableName}] REBUILD;' + CHAR(13) FROM sys.indexes i INNER JOIN sys.tables t ON i.object_id = t.object_id WHERE t.name = @tableName AND i.type = 2 AND i.is_disabled = 1; EXEC sp_executesql @sql;"; await connection.ExecuteAsync( new CommandDefinition(sql, new { tableName }, commandTimeout: 3600, cancellationToken: cancellationToken)); } private static async Task RebuildIndexesAsync( SqlConnection connection, string tableName, CancellationToken cancellationToken) { var sql = $"ALTER INDEX ALL ON [{tableName}] REBUILD WITH (FILLFACTOR = 95)"; await connection.ExecuteAsync( new CommandDefinition(sql, commandTimeout: 3600, cancellationToken: cancellationToken)); } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Services/BulkMergeHelper.cs git commit -m "feat(datasync): implement MassInsertAsync with index management" ``` --- ## Task 6: Create Merge Configurations Directory **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/` (directory) **Step 1: Create directory** Run: `mkdir -p src/JdeScoping.DataSync/Configuration/MergeConfigurations` **Step 2: Commit** ```bash git add . git commit -m "chore(datasync): add MergeConfigurations directory" ``` --- ## Task 7: Create WorkOrder Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/WorkOrderMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.WorkOrders; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for WorkOrder entities. /// public sealed class WorkOrderMergeConfiguration : IMergeConfiguration { public string TableName => "WorkOrder"; public Expression> MatchOn => x => new { x.WorkOrderNumber, x.BranchCode }; public Expression>? UpdateColumns => x => new { x.LotNumber, x.ItemNumber, x.ShortItemNumber, x.ParentWorkOrderNumber, x.OrderQuantity, x.HeldQuantity, x.ShippedQuantity, x.StatusCode, x.StatusCodeUpdateDt, x.IssueDate, x.StartDate, x.RoutingType }; public Expression>? UpdateWhen => (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt; public Expression>? InsertColumns => null; // All columns } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/WorkOrderMergeConfiguration.cs git commit -m "feat(datasync): add WorkOrderMergeConfiguration" ``` --- ## Task 8: Create Lot Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/LotMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.Inventory; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for Lot entities. /// public sealed class LotMergeConfiguration : IMergeConfiguration { public string TableName => "Lot"; public Expression> MatchOn => x => new { x.LotNumber, x.BranchCode }; public Expression>? UpdateColumns => x => new { x.ShortItemNumber, x.ItemNumber, x.SupplierCode, x.StatusCode, x.Memo1, x.Memo2, x.Memo3 }; public Expression>? UpdateWhen => (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt; public Expression>? InsertColumns => null; } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/LotMergeConfiguration.cs git commit -m "feat(datasync): add LotMergeConfiguration" ``` --- ## Task 9: Create LotUsage Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/LotUsageMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.Inventory; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for LotUsage entities. /// public sealed class LotUsageMergeConfiguration : IMergeConfiguration { public string TableName => "LotUsage"; public Expression> MatchOn => x => x.UniqueId; public Expression>? UpdateColumns => x => new { x.WorkOrderNumber, x.LotNumber, x.BranchCode, x.ShortItemNumber, x.Quantity }; public Expression>? UpdateWhen => (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt; public Expression>? InsertColumns => null; } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/LotUsageMergeConfiguration.cs git commit -m "feat(datasync): add LotUsageMergeConfiguration" ``` --- ## Task 10: Create Item Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/ItemMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.Inventory; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for Item entities. /// public sealed class ItemMergeConfiguration : IMergeConfiguration { public string TableName => "Item"; public Expression> MatchOn => x => x.ShortItemNumber; public Expression>? UpdateColumns => x => new { x.ItemNumber, x.Description, x.PlanningFamily, x.StockingType }; public Expression>? UpdateWhen => (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt; public Expression>? InsertColumns => null; } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/ItemMergeConfiguration.cs git commit -m "feat(datasync): add ItemMergeConfiguration" ``` --- ## Task 11: Create WorkCenter Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/WorkCenterMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.Organization; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for WorkCenter entities. /// public sealed class WorkCenterMergeConfiguration : IMergeConfiguration { public string TableName => "WorkCenter"; public Expression> MatchOn => x => x.Code; public Expression>? UpdateColumns => x => x.Description; public Expression>? UpdateWhen => (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt; public Expression>? InsertColumns => null; } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/WorkCenterMergeConfiguration.cs git commit -m "feat(datasync): add WorkCenterMergeConfiguration" ``` --- ## Task 12: Create ProfitCenter Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/ProfitCenterMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.Organization; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for ProfitCenter entities. /// public sealed class ProfitCenterMergeConfiguration : IMergeConfiguration { public string TableName => "ProfitCenter"; public Expression> MatchOn => x => x.Code; public Expression>? UpdateColumns => x => x.Description; public Expression>? UpdateWhen => (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt; public Expression>? InsertColumns => null; } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/ProfitCenterMergeConfiguration.cs git commit -m "feat(datasync): add ProfitCenterMergeConfiguration" ``` --- ## Task 13: Create JdeUser Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/JdeUserMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.Organization; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for JdeUser entities. /// public sealed class JdeUserMergeConfiguration : IMergeConfiguration { public string TableName => "JdeUser"; public Expression> MatchOn => x => x.AddressNumber; public Expression>? UpdateColumns => x => new { x.UserId, x.FullName }; public Expression>? UpdateWhen => (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt; public Expression>? InsertColumns => null; } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/JdeUserMergeConfiguration.cs git commit -m "feat(datasync): add JdeUserMergeConfiguration" ``` --- ## Task 14: Create Branch Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/BranchMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.Organization; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for Branch entities. /// public sealed class BranchMergeConfiguration : IMergeConfiguration { public string TableName => "Branch"; public Expression> MatchOn => x => x.Code; public Expression>? UpdateColumns => x => x.Description; public Expression>? UpdateWhen => (src, tgt) => src.LastUpdateDt > tgt.LastUpdateDt; public Expression>? InsertColumns => null; } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/BranchMergeConfiguration.cs git commit -m "feat(datasync): add BranchMergeConfiguration" ``` --- ## Task 15: Create MisData Merge Configuration **Files:** - Create: `src/JdeScoping.DataSync/Configuration/MergeConfigurations/MisDataMergeConfiguration.cs` **Step 1: Create the configuration** ```csharp using System.Linq.Expressions; using JdeScoping.Core.Models.Quality; using JdeScoping.DataSync.Contracts; namespace JdeScoping.DataSync.Configuration.MergeConfigurations; /// /// Merge configuration for MisData entities. /// public sealed class MisDataMergeConfiguration : IMergeConfiguration { public string TableName => "MisData"; public Expression> MatchOn => x => new { x.ItemNumber, x.BranchCode, x.SequenceNumber, x.MisNumber, x.CharNumber }; public Expression>? UpdateColumns => x => new { x.RevId, x.TestDescription, x.SamplingType, x.SamplingValue, x.ToolsGauges, x.WorkInstructions, x.Status, x.ReleaseDate }; // MisData doesn't have LastUpdateDt, so always update on match public Expression>? UpdateWhen => null; public Expression>? InsertColumns => null; } ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/Configuration/MergeConfigurations/MisDataMergeConfiguration.cs git commit -m "feat(datasync): add MisDataMergeConfiguration" ``` --- ## Task 16: Update DI Registration **Files:** - Modify: `src/JdeScoping.DataSync/DependencyInjection/ServiceCollectionExtensions.cs` **Step 1: Add using statements at top of file** ```csharp using JdeScoping.DataSync.Configuration.MergeConfigurations; ``` **Step 2: Add merge configuration registrations after bulk merge services section (around line 53)** ```csharp // Register merge configuration registry services.AddSingleton(); // Register merge configurations - explicit registration per entity services.AddSingleton, WorkOrderMergeConfiguration>(); services.AddSingleton, LotMergeConfiguration>(); services.AddSingleton, LotUsageMergeConfiguration>(); services.AddSingleton, ItemMergeConfiguration>(); services.AddSingleton, WorkCenterMergeConfiguration>(); services.AddSingleton, ProfitCenterMergeConfiguration>(); services.AddSingleton, JdeUserMergeConfiguration>(); services.AddSingleton, BranchMergeConfiguration>(); services.AddSingleton, MisDataMergeConfiguration>(); ``` **Step 3: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 4: Commit** ```bash git add src/JdeScoping.DataSync/DependencyInjection/ServiceCollectionExtensions.cs git commit -m "feat(datasync): register merge configurations in DI" ``` --- ## Task 17: Add Unit Tests for MergeConfigurationRegistry **Files:** - Create: `tests/JdeScoping.DataSync.Tests/Services/MergeConfigurationRegistryTests.cs` **Step 1: Create the test file** ```csharp using JdeScoping.Core.Models.WorkOrders; using JdeScoping.DataSync.Configuration.MergeConfigurations; using JdeScoping.DataSync.Contracts; using JdeScoping.DataSync.Services; using Microsoft.Extensions.DependencyInjection; using Shouldly; namespace JdeScoping.DataSync.Tests.Services; public class MergeConfigurationRegistryTests { [Fact] public void GetConfiguration_RegisteredType_ReturnsConfiguration() { // Arrange var services = new ServiceCollection(); services.AddSingleton, WorkOrderMergeConfiguration>(); var provider = services.BuildServiceProvider(); var registry = new MergeConfigurationRegistry(provider); // Act var config = registry.GetConfiguration(); // Assert config.ShouldNotBeNull(); config.TableName.ShouldBe("WorkOrder"); } [Fact] public void GetConfiguration_UnregisteredType_ThrowsInvalidOperationException() { // Arrange var services = new ServiceCollection(); var provider = services.BuildServiceProvider(); var registry = new MergeConfigurationRegistry(provider); // Act & Assert var ex = Should.Throw(() => registry.GetConfiguration()); ex.Message.ShouldContain("UnregisteredEntity"); } [Fact] public void HasConfiguration_RegisteredType_ReturnsTrue() { // Arrange var services = new ServiceCollection(); services.AddSingleton, WorkOrderMergeConfiguration>(); var provider = services.BuildServiceProvider(); var registry = new MergeConfigurationRegistry(provider); // Act var result = registry.HasConfiguration(); // Assert result.ShouldBeTrue(); } [Fact] public void HasConfiguration_UnregisteredType_ReturnsFalse() { // Arrange var services = new ServiceCollection(); var provider = services.BuildServiceProvider(); var registry = new MergeConfigurationRegistry(provider); // Act var result = registry.HasConfiguration(); // Assert result.ShouldBeFalse(); } private class UnregisteredEntity { } } ``` **Step 2: Verify tests pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~MergeConfigurationRegistryTests"` Expected: All 4 tests pass **Step 3: Commit** ```bash git add tests/JdeScoping.DataSync.Tests/Services/MergeConfigurationRegistryTests.cs git commit -m "test(datasync): add MergeConfigurationRegistry unit tests" ``` --- ## Task 18: Add Unit Tests for BulkMergeHelper.MassInsertAsync **Files:** - Modify: `tests/JdeScoping.DataSync.Tests/Services/BulkMergeHelperTests.cs` **Step 1: Add MassInsertAsync test methods** Add these tests to the existing test class: ```csharp #region MassInsertAsync Tests [Fact] public async Task MassInsertAsync_NullData_ThrowsArgumentNullException() { // Arrange var sut = CreateSut(); // Act & Assert await Should.ThrowAsync( () => sut.MassInsertAsync(null!, "TestTable")); } [Fact] public async Task MassInsertAsync_NullDestination_ThrowsArgumentException() { // Arrange var sut = CreateSut(); var data = AsyncEnumerable.Empty(); // Act & Assert await Should.ThrowAsync( () => sut.MassInsertAsync(data, null!)); } [Fact] public async Task MassInsertAsync_EmptyDestination_ThrowsArgumentException() { // Arrange var sut = CreateSut(); var data = AsyncEnumerable.Empty(); // Act & Assert await Should.ThrowAsync( () => sut.MassInsertAsync(data, "")); } [Fact] public async Task MassInsertAsync_EmptyData_ReturnsZeroRows() { // Arrange SetupConnectionFactory(); var sut = CreateSut(); var data = AsyncEnumerable.Empty(); // Act var result = await sut.MassInsertAsync(data, "TestTable"); // Assert result.TotalRowsInserted.ShouldBe(0); } #endregion ``` **Step 2: Verify tests pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~BulkMergeHelperTests"` Expected: All tests pass **Step 3: Commit** ```bash git add tests/JdeScoping.DataSync.Tests/Services/BulkMergeHelperTests.cs git commit -m "test(datasync): add BulkMergeHelper.MassInsertAsync unit tests" ``` --- ## Task 19: Refactor TableSyncOperation - Update Dependencies **Files:** - Modify: `src/JdeScoping.DataSync/Services/TableSyncOperation.cs` **Step 1: Update using statements and dependencies** Replace the class fields and constructor (lines 22-54) with: ```csharp public class TableSyncOperation : ITableSyncOperation { private readonly IServiceProvider _serviceProvider; private readonly IDbConnectionFactory _connectionFactory; private readonly IDataUpdateRepository _updateRepository; private readonly IBulkMergeHelper _bulkMergeHelper; private readonly IMergeConfigurationRegistry _configRegistry; private readonly IOptions _options; private readonly ILogger _logger; private readonly DataSyncMetrics _metrics; /// /// Initializes a new instance of the class. /// public TableSyncOperation( IServiceProvider serviceProvider, IDbConnectionFactory connectionFactory, IDataUpdateRepository updateRepository, IBulkMergeHelper bulkMergeHelper, IMergeConfigurationRegistry configRegistry, IOptions options, ILogger logger, DataSyncMetrics metrics) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _updateRepository = updateRepository ?? throw new ArgumentNullException(nameof(updateRepository)); _bulkMergeHelper = bulkMergeHelper ?? throw new ArgumentNullException(nameof(bulkMergeHelper)); _configRegistry = configRegistry ?? throw new ArgumentNullException(nameof(configRegistry)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); } ``` **Step 2: Verify build fails** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build fails - references to removed dependencies **Step 3: Commit partial refactor** ```bash git add src/JdeScoping.DataSync/Services/TableSyncOperation.cs git commit -m "refactor(datasync): update TableSyncOperation dependencies" ``` --- ## Task 20: Refactor TableSyncOperation - Update ExecuteSyncCoreAsync **Files:** - Modify: `src/JdeScoping.DataSync/Services/TableSyncOperation.cs` **Step 1: Replace ExecuteSyncCoreAsync method** Replace the entire `ExecuteSyncCoreAsync` method with: ```csharp /// /// Core sync logic that handles mass vs incremental updates. /// private async Task ExecuteSyncCoreAsync(DataUpdateTask task, CancellationToken cancellationToken) { // Get the fetcher for this entity type var fetcherType = ResolveFetcherType(task.Config.FetcherTypeName); var fetcher = _serviceProvider.GetRequiredService(fetcherType); // Use reflection to call FetchAsync on the fetcher var fetchMethod = fetcher.GetType().GetMethod("FetchAsync") ?? throw new InvalidOperationException($"FetchAsync method not found on {fetcher.GetType().Name}"); var asyncEnumerable = fetchMethod.Invoke(fetcher, [task.MinimumDt, cancellationToken]); // Get the element type for typed operations var (_, elementType) = FindGetAsyncEnumerator(asyncEnumerable!.GetType()); if (elementType == null || !elementType.IsClass) { throw new InvalidOperationException( $"Fetcher element type must be a class: {asyncEnumerable.GetType().Name}"); } // Handle mass update with truncation if (task.UpdateType == UpdateTypes.Mass && task.ScheduleConfig.PrepurgeData) { return await ExecuteMassUpdateAsync( asyncEnumerable!, task, elementType, cancellationToken); } // Handle incremental update with merge return await ExecuteIncrementalUpdateAsync( asyncEnumerable!, task, elementType, cancellationToken); } ``` **Step 2: Commit** ```bash git add src/JdeScoping.DataSync/Services/TableSyncOperation.cs git commit -m "refactor(datasync): update ExecuteSyncCoreAsync to use new components" ``` --- ## Task 21: Refactor TableSyncOperation - Replace Mass Update Methods **Files:** - Modify: `src/JdeScoping.DataSync/Services/TableSyncOperation.cs` **Step 1: Replace ExecuteMassUpdateWithTruncateAsync and ExecuteMassUpdateTypedAsync** Replace both methods with: ```csharp /// /// Executes mass update using BulkMergeHelper. /// private Task ExecuteMassUpdateAsync( object asyncEnumerable, DataUpdateTask task, Type elementType, CancellationToken cancellationToken) { _logger.LogDebug("Executing mass update for {Table}", task.TableName); // Use typed helper to call MassInsertAsync with correct type parameter var helper = typeof(TableSyncOperation) .GetMethod(nameof(ExecuteMassUpdateTypedAsync), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .MakeGenericMethod(elementType); return (Task)helper.Invoke(this, [ asyncEnumerable, task, cancellationToken ])!; } /// /// Typed helper for mass update. /// private async Task ExecuteMassUpdateTypedAsync( IAsyncEnumerable data, DataUpdateTask task, CancellationToken cancellationToken) where T : class { var config = _configRegistry.GetConfiguration(); var result = await _bulkMergeHelper.MassInsertAsync( data, config.TableName, rebuildIndexes: task.ScheduleConfig.ReIndexData, batchSize: _options.Value.BulkCopyBatchSize, cancellationToken: cancellationToken); // Run post processor if configured await RunPostProcessorAsync(task, cancellationToken); return result.TotalRowsInserted; } ``` **Step 2: Commit** ```bash git add src/JdeScoping.DataSync/Services/TableSyncOperation.cs git commit -m "refactor(datasync): replace mass update with BulkMergeHelper.MassInsertAsync" ``` --- ## Task 22: Refactor TableSyncOperation - Replace Incremental Update Methods **Files:** - Modify: `src/JdeScoping.DataSync/Services/TableSyncOperation.cs` **Step 1: Replace ExecuteIncrementalUpdateAsync and ProcessBatchAsync/ProcessBatchTypedAsync** Replace all three methods with: ```csharp /// /// Executes incremental update using BulkMergeHelper.MergeAsync. /// private Task ExecuteIncrementalUpdateAsync( object asyncEnumerable, DataUpdateTask task, Type elementType, CancellationToken cancellationToken) { _logger.LogDebug("Executing incremental update for {Table}", task.TableName); // Use typed helper to call MergeAsync with correct type parameter var helper = typeof(TableSyncOperation) .GetMethod(nameof(ExecuteIncrementalUpdateTypedAsync), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .MakeGenericMethod(elementType); return (Task)helper.Invoke(this, [ asyncEnumerable, task, cancellationToken ])!; } /// /// Typed helper for incremental update. /// private async Task ExecuteIncrementalUpdateTypedAsync( IAsyncEnumerable data, DataUpdateTask task, CancellationToken cancellationToken) where T : class { var config = _configRegistry.GetConfiguration(); var result = await _bulkMergeHelper.MergeAsync( data, config.TableName, config.MatchOn, config.UpdateColumns, config.UpdateWhen, config.InsertColumns, batchSize: _options.Value.BatchSize, cancellationToken: cancellationToken); // Run post processor if configured await RunPostProcessorAsync(task, cancellationToken); return result.TotalRowsProcessed; } ``` **Step 2: Remove unused methods** Delete these methods (they are no longer needed): - `CollectRecordsAsync` **Step 3: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 4: Commit** ```bash git add src/JdeScoping.DataSync/Services/TableSyncOperation.cs git commit -m "refactor(datasync): replace incremental update with BulkMergeHelper.MergeAsync" ``` --- ## Task 23: Update TableSyncOperation Unit Tests **Files:** - Modify: `tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs` **Step 1: Update test class dependencies** Replace the test class fields and constructor with: ```csharp public class TableSyncOperationTests { private readonly IDbConnectionFactory _connectionFactory; private readonly IDataUpdateRepository _updateRepository; private readonly IBulkMergeHelper _bulkMergeHelper; private readonly IMergeConfigurationRegistry _configRegistry; private readonly IOptions _options; private readonly DataSyncMetrics _metrics; private readonly IServiceProvider _serviceProvider; public TableSyncOperationTests() { _connectionFactory = Substitute.For(); _updateRepository = Substitute.For(); _bulkMergeHelper = Substitute.For(); _configRegistry = Substitute.For(); _options = Options.Create(new DataSyncOptions { BatchSize = 1000, BulkCopyBatchSize = 100 }); var services = new ServiceCollection(); services.AddMetrics(); var provider = services.BuildServiceProvider(); var meterFactory = provider.GetRequiredService(); _metrics = new DataSyncMetrics(meterFactory); _serviceProvider = Substitute.For(); } ``` **Step 2: Update CreateSut helper method** ```csharp private TableSyncOperation CreateSut() { return new TableSyncOperation( _serviceProvider, _connectionFactory, _updateRepository, _bulkMergeHelper, _configRegistry, _options, NullLogger.Instance, _metrics); } ``` **Step 3: Update test methods to use new mocks** Update the mass update test to mock `IBulkMergeHelper.MassInsertAsync` instead of `IStagingTableManager.ExecuteMassUpdateAsync`. **Step 4: Verify tests pass** Run: `dotnet test tests/JdeScoping.DataSync.Tests --filter "FullyQualifiedName~TableSyncOperationTests"` Expected: Tests pass (some may need adjustment) **Step 5: Commit** ```bash git add tests/JdeScoping.DataSync.Tests/TableSyncOperationTests.cs git commit -m "test(datasync): update TableSyncOperationTests for new dependencies" ``` --- ## Task 24: Remove IStagingTableManager from DI Registration **Files:** - Modify: `src/JdeScoping.DataSync/DependencyInjection/ServiceCollectionExtensions.cs` **Step 1: Remove the staging manager registration** Delete this line: ```csharp services.AddScoped(); ``` **Step 2: Verify build** Run: `dotnet build src/JdeScoping.DataSync/JdeScoping.DataSync.csproj` Expected: Build succeeded **Step 3: Commit** ```bash git add src/JdeScoping.DataSync/DependencyInjection/ServiceCollectionExtensions.cs git commit -m "refactor(datasync): remove IStagingTableManager from DI" ``` --- ## Task 25: Delete StagingTableManager Files **Files:** - Delete: `src/JdeScoping.DataSync/Contracts/IStagingTableManager.cs` - Delete: `src/JdeScoping.DataSync/Services/StagingTableManager.cs` - Delete: `tests/JdeScoping.DataSync.Tests/StagingTableManagerTests.cs` - Delete: `tests/JdeScoping.DataSync.IntegrationTests/StagingTableManagerTests.cs` **Step 1: Delete the files** ```bash rm src/JdeScoping.DataSync/Contracts/IStagingTableManager.cs rm src/JdeScoping.DataSync/Services/StagingTableManager.cs rm tests/JdeScoping.DataSync.Tests/StagingTableManagerTests.cs rm tests/JdeScoping.DataSync.IntegrationTests/StagingTableManagerTests.cs ``` **Step 2: Verify build** Run: `dotnet build` Expected: Build succeeded **Step 3: Verify all tests pass** Run: `dotnet test` Expected: All tests pass **Step 4: Commit** ```bash git add -A git commit -m "refactor(datasync): remove StagingTableManager and related tests" ``` --- ## Task 26: Final Verification **Step 1: Full solution build** Run: `dotnet build` Expected: Build succeeded with no warnings **Step 2: Run all tests** Run: `dotnet test` Expected: All tests pass **Step 3: Review changes** Run: `git log --oneline -15` Verify all commits are present **Step 4: Final commit summary** ```bash git log --oneline HEAD~20..HEAD ``` --- ## Summary This plan migrates `TableSyncOperation` from the old `IStagingTableManager`/`TableSpec` approach to the new `IBulkMergeHelper`/`IMergeConfiguration` pattern: 1. **Tasks 1-2:** Create merge configuration interfaces 2. **Tasks 3-5:** Add MassInsertAsync to BulkMergeHelper 3. **Tasks 6-15:** Create merge configurations for all 9 entity types 4. **Task 16:** Update DI registration 5. **Tasks 17-18:** Add unit tests 6. **Tasks 19-23:** Refactor TableSyncOperation 7. **Tasks 24-25:** Remove old staging table code 8. **Task 26:** Final verification