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.
This commit is contained in:
Johannes Millan 2026-01-21 21:36:26 +01:00
parent cfb1c656dd
commit 86850c711a
2 changed files with 108 additions and 6 deletions

View file

@ -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();

View file

@ -550,17 +550,29 @@ export class ConflictResolutionService {
conflict: EntityConflict,
): Promise<Operation | undefined> {
// 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<string, unknown>;
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.