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.