diff --git a/.gitignore b/.gitignore index e4a2766..824a54a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ logs/ config.yaml config.local.yaml config.pilot.yaml + +.claude/ +.workbuddy/ \ No newline at end of file diff --git a/sql/02_sync_apply.sql b/sql/02_sync_apply.sql index 740fe1b..1d332cf 100644 --- a/sql/02_sync_apply.sql +++ b/sql/02_sync_apply.sql @@ -3,6 +3,13 @@ -- (excluding the ID key, computed, identity and rowversion columns) and runs a -- dynamic-SQL MERGE (last-write-wins by SourceLogID DESC) for Insert/Update and a -- DELETE for the last op = Delete. SET IDENTITY_INSERT ON preserves Access PKs. +-- +-- CORRECTNESS NOTE: a SINGLE ranked CTE (rn=1 per RecordID, over ALL pending ops +-- regardless of OperateType) is read by BOTH the upsert and delete branches. +-- Earlier this was two independent ranked CTEs (one over Insert/Update, one over +-- Delete), which let a stale Delete outrank a newer Insert for the same RecordID +-- and silently drop a row whose true last op was an Insert. Routing off the one +-- winning row's OperateType guarantees only the genuine last op wins. CREATE OR ALTER PROCEDURE dbo.usp_SyncApply @MaxRetries INT = 5 @@ -54,16 +61,22 @@ BEGIN IF @cols IS NOT NULL BEGIN - -- Upsert: last write wins (ROW_NUMBER over SourceLogID DESC). + -- Upsert: winners (rn=1) whose winning OperateType is + -- Insert/Update and carry RowData. The ranked CTE covers ALL + -- pending ops so the rn=1 row is the true last op for that + -- RecordID; an earlier Delete can no longer outrank a newer + -- Insert for the same RecordID. SET @sql = N'SET IDENTITY_INSERT ' + @FullName + N' ON;' + + N';WITH ranked AS (' + + N' SELECT RecordID, OperateType, RowData,' + + N' ROW_NUMBER() OVER (PARTITION BY RecordID ORDER BY SourceLogID DESC) rn' + + N' FROM dbo.SyncQueue' + + N' WHERE TargetSchema=@sch AND TargetTable=@tbl AND Status=''pending''' + + N')' + N'MERGE ' + @FullName + N' WITH (HOLDLOCK) AS tgt' + N' USING (' - + N' SELECT RecordID, RowData FROM (' - + N' SELECT *, ROW_NUMBER() OVER (PARTITION BY RecordID ORDER BY SourceLogID DESC) rn' - + N' FROM dbo.SyncQueue' - + N' WHERE TargetSchema=@sch AND TargetTable=@tbl AND Status=''pending''' - + N' AND OperateType IN (''Insert'',''Update'') AND RowData IS NOT NULL' - + N' ) x WHERE rn=1' + + N' SELECT RecordID, RowData FROM ranked' + + N' WHERE rn=1 AND OperateType IN (''Insert'',''Update'') AND RowData IS NOT NULL' + N') AS src ON tgt.ID = TRY_CAST(src.RecordID AS int)' + N' WHEN MATCHED THEN UPDATE SET ' + @upd + N' WHEN NOT MATCHED THEN INSERT (ID,' + @cols + N')' @@ -72,16 +85,17 @@ BEGIN EXEC sp_executesql @sql, N'@sch NVARCHAR(128),@tbl NVARCHAR(255)', @sch, @tbl; - -- Delete: last op = Delete wins. - SET @sql = N'DELETE t FROM ' + @FullName + N' t' - + N' JOIN (' - + N' SELECT RecordID FROM (' - + N' SELECT *, ROW_NUMBER() OVER (PARTITION BY RecordID ORDER BY SourceLogID DESC) rn' - + N' FROM dbo.SyncQueue' - + N' WHERE TargetSchema=@sch AND TargetTable=@tbl AND Status=''pending''' - + N' AND OperateType=''Delete''' - + N' ) x WHERE rn=1' - + N') d ON t.ID = TRY_CAST(d.RecordID AS int);'; + -- Delete: winners (rn=1) whose winning OperateType is Delete. + -- Same ranked CTE — only the genuine last op can be a delete. + SET @sql = N';WITH ranked AS (' + + N' SELECT RecordID, OperateType,' + + N' ROW_NUMBER() OVER (PARTITION BY RecordID ORDER BY SourceLogID DESC) rn' + + N' FROM dbo.SyncQueue' + + N' WHERE TargetSchema=@sch AND TargetTable=@tbl AND Status=''pending''' + + N')' + + N'DELETE t FROM ' + @FullName + N' t' + + N' JOIN (SELECT RecordID FROM ranked WHERE rn=1 AND OperateType=''Delete'') d' + + N' ON t.ID = TRY_CAST(d.RecordID AS int);'; EXEC sp_executesql @sql, N'@sch NVARCHAR(128),@tbl NVARCHAR(255)', @sch, @tbl; END