From 86850c711a60f09439628030ac0f5ff2d4c713de Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 21 Jan 2026 21:36:26 +0100 Subject: [PATCH] fix(sync): restore entity from DELETE payload when UPDATE wins LWW conflict When a remote DELETE is applied before LWW resolution and the local UPDATE wins (newer timestamp), extract the entity from the DELETE operation payload to recreate it, preventing data loss from the race condition. --- .../sync/conflict-resolution.service.spec.ts | 61 +++++++++++++++++++ .../sync/conflict-resolution.service.ts | 53 ++++++++++++++-- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/app/op-log/sync/conflict-resolution.service.spec.ts b/src/app/op-log/sync/conflict-resolution.service.spec.ts index bf8d7590c..82014f4b2 100644 --- a/src/app/op-log/sync/conflict-resolution.service.spec.ts +++ b/src/app/op-log/sync/conflict-resolution.service.spec.ts @@ -717,6 +717,67 @@ describe('ConflictResolutionService', () => { expect(mockOpLogStore.markRejected).toHaveBeenCalledWith(['local-del']); }); + it('should extract entity from DELETE payload when UPDATE wins but entity not in store', () => { + // This tests the helper method that extracts entity state from DELETE operations + // Used when remote DELETE is applied first, then local UPDATE wins LWW + const taskEntity = { + id: 'task-1', + title: 'Test Task', + projectId: 'project-1', + tagIds: [], + }; + + const conflict: EntityConflict = createConflict( + 'task-1', + [ + { + ...createOpWithTimestamp('local-upd', 'client-a', Date.now()), + opType: OpType.Update, + payload: { task: taskEntity }, + }, + ], + [ + { + ...createOpWithTimestamp('remote-del', 'client-b', Date.now() - 1000), + opType: OpType.Delete, + payload: { task: taskEntity }, // DELETE payload contains the deleted entity + }, + ], + ); + + // Call the private extraction method + const extractedEntity = (service as any)._extractEntityFromDeleteOperation( + conflict, + ); + + // Verify it extracted the entity from the DELETE operation's payload + expect(extractedEntity).toEqual(taskEntity); + }); + + it('should return undefined when no DELETE operation in conflict', () => { + const conflict: EntityConflict = createConflict( + 'task-1', + [ + { + ...createOpWithTimestamp('local-upd', 'client-a', Date.now()), + opType: OpType.Update, + }, + ], + [ + { + ...createOpWithTimestamp('remote-upd', 'client-b', Date.now() - 1000), + opType: OpType.Update, + }, + ], + ); + + const extractedEntity = (service as any)._extractEntityFromDeleteOperation( + conflict, + ); + + expect(extractedEntity).toBeUndefined(); + }); + it('should handle CREATE vs CREATE conflict using LWW', async () => { // Two clients create entity with same ID (rare but possible) const now = Date.now(); diff --git a/src/app/op-log/sync/conflict-resolution.service.ts b/src/app/op-log/sync/conflict-resolution.service.ts index 01d322d67..b0d75151e 100644 --- a/src/app/op-log/sync/conflict-resolution.service.ts +++ b/src/app/op-log/sync/conflict-resolution.service.ts @@ -550,17 +550,29 @@ export class ConflictResolutionService { conflict: EntityConflict, ): Promise { // Get current entity state from store - const entityState = await this.getCurrentEntityState( + let entityState = await this.getCurrentEntityState( conflict.entityType, conflict.entityId, ); if (entityState === undefined) { - OpLog.warn( - `ConflictResolutionService: Cannot create local-win op - entity not found: ` + - `${conflict.entityType}:${conflict.entityId}`, - ); - return undefined; + // Try to extract entity from remote DELETE operation + // This handles the case where a remote DELETE was applied before LWW resolution, + // and the local UPDATE wins. We need to recreate the entity from the DELETE payload. + entityState = this._extractEntityFromDeleteOperation(conflict); + + if (entityState !== undefined) { + OpLog.warn( + `ConflictResolutionService: Extracted entity from DELETE op for LWW update: ` + + `${conflict.entityType}:${conflict.entityId}`, + ); + } else { + OpLog.warn( + `ConflictResolutionService: Cannot create local-win op - entity not found: ` + + `${conflict.entityType}:${conflict.entityId}`, + ); + return undefined; + } } // Get client ID @@ -596,6 +608,35 @@ export class ConflictResolutionService { ); } + /** + * Extracts entity state from a remote DELETE operation payload. + * + * When a remote DELETE wins the conflict but we need the entity state for LWW resolution, + * we can extract it from the DELETE operation's payload (which contains the deleted entity). + * + * @param conflict - The conflict containing remote DELETE operation + * @returns Entity state from DELETE payload, or undefined if not found + */ + private _extractEntityFromDeleteOperation( + conflict: EntityConflict, + ): unknown | undefined { + // Find the DELETE operation in remote ops + const deleteOp = conflict.remoteOps.find((op) => op.opType === OpType.Delete); + if (!deleteOp) { + return undefined; + } + + // Extract entity from payload based on entity type + // For TASK: payload.task + // For PROJECT: payload.project + // For TAG: payload.tag + // etc. + const payload = deleteOp.payload as Record; + const entityKey = conflict.entityType.toLowerCase(); + + return payload[entityKey]; + } + /** * Gets the current state of an entity from the NgRx store. * Uses the entity registry to look up the appropriate selector.