diff --git a/src/lib/projector/effects.ts b/src/lib/projector/effects.ts index 782c24dfc..6a379d646 100644 --- a/src/lib/projector/effects.ts +++ b/src/lib/projector/effects.ts @@ -27,7 +27,12 @@ export function deriveLiveEffects( }); } } - if (next.needsResync && !previous.needsResync) { + if ( + next.needsResync && + (!previous.needsResync || + next.resyncReason !== previous.resyncReason || + next.resyncTargetCursor !== previous.resyncTargetCursor) + ) { effects.push({ type: 'request_resync', reason: next.resyncReason || 'unknown_gap', diff --git a/test/unit/lib/projector.test.ts b/test/unit/lib/projector.test.ts index 824971bad..3f305391e 100644 --- a/test/unit/lib/projector.test.ts +++ b/test/unit/lib/projector.test.ts @@ -147,6 +147,30 @@ describe('projector pipeline', () => { }); }); + it('requests another resync when a later gap extends the missing range', () => { + const initial = reduceProjectView( + createProjectViewState('project-1', 'live'), + normalizeEvent(event()) + ); + const firstGapEvent = normalizeEvent( + event({ event_id: 'event-3', cloud_cursor: 3, run_sequence: 3 }) + ); + const firstGap = reduceProjectView(initial, firstGapEvent); + const laterGapEvent = normalizeEvent( + event({ event_id: 'event-4', cloud_cursor: 4, run_sequence: 4 }) + ); + const laterGap = reduceProjectView(firstGap, laterGapEvent); + + expect( + deriveLiveEffects(firstGap, laterGap, laterGapEvent, 'live') + ).toEqual([ + { + type: 'request_resync', + reason: 'run_sequence_gap:run-1:2:4', + }, + ]); + }); + it('derives effects only in live mode', () => { const normalized = normalizeEvent( event({ event_type: 'run.completed', legacy_step: 'end' })