diff --git a/src/app/op-log/sync/conflict-resolution.disjoint-merge.spec.ts b/src/app/op-log/sync/conflict-resolution.disjoint-merge.spec.ts index 1063eef6eb..28ae72e28c 100644 --- a/src/app/op-log/sync/conflict-resolution.disjoint-merge.spec.ts +++ b/src/app/op-log/sync/conflict-resolution.disjoint-merge.spec.ts @@ -188,6 +188,38 @@ describe('ConflictResolutionService — SPAP-14 disjoint-field merge', () => { expect((await journal.list('unreviewed')).length).toBe(0); }); + // ── (a2) merge-only sync counts the synthesized op for re-upload ──────────── + it('(a2) counts the synthesized merged op in localWinOpsCreated (drives re-upload)', async () => { + mockStore.select.and.returnValue( + of({ id: 'task-1', title: 'Local title', notes: 'base notes' }), + ); + + const localOp = op({ + id: 'local-1', + clientId: 'A', + vectorClock: { A: 1 }, + timestamp: 2000, + payload: { task: { id: 'task-1', changes: { title: 'Local title' } } }, + }); + const remoteOp = op({ + id: 'remote-1', + clientId: 'B', + vectorClock: { B: 1 }, + timestamp: 1000, + payload: { task: { id: 'task-1', changes: { notes: 'Remote notes' } } }, + }); + + const result = await service.autoResolveConflictsLWW([ + conflictOf([localOp], [remoteOp]), + ]); + + // No LWW local-win ops here — the single synthesized merged op is the sole + // pending-local op. It MUST be counted or the caller's immediate re-upload + // is skipped and the sync falsely reports IN_SYNC while the merge is unsynced. + expect(mergedOpArgs()).toBeDefined(); + expect(result.localWinOpsCreated).toBe(1); + }); + // ── (b) title vs title → LWW unchanged ───────────────────────────────────── it('(b) leaves same-field (title-vs-title) conflicts to LWW (journal unreviewed)', async () => { mockStore.select.and.returnValue(of({ id: 'task-1', title: 'Local title' })); diff --git a/src/app/op-log/sync/conflict-resolution.service.ts b/src/app/op-log/sync/conflict-resolution.service.ts index 2194c7a868..755235a5d3 100644 --- a/src/app/op-log/sync/conflict-resolution.service.ts +++ b/src/app/op-log/sync/conflict-resolution.service.ts @@ -151,6 +151,12 @@ export class ConflictResolutionService { * same reference flows detection → autoResolveConflictsLWW), so a WeakSet * both avoids mutating the shared type and cannot leak across sync cycles. * Purely a side-channel: it never changes which op resolution picks. + * + * FRAGILE: attribution depends on the SAME EntityConflict reference surviving + * from detection (`.add`) to resolution (`.has`). A future refactor that + * clones or rebuilds the conflict object between those points would silently + * drop the `clock-corruption-suspected` classification (no error, just wrong + * journal reason). Keep the reference stable or switch to an explicit flag. */ private readonly _corruptionSuspectedConflicts = new WeakSet(); @@ -597,7 +603,15 @@ export class ConflictResolutionService { const isValid = await this._validateAndRepairAfterResolution(); if (!isValid) this.sessionValidation.setFailed(); - return { localWinOpsCreated: newLocalWinOps.length }; + // Count both LWW local-win ops AND disjoint-merge ops (STEP 3b): each merge + // appended a synthesized pending-local op that still needs uploading. The + // caller uses this count to trigger the immediate re-upload + // (immediate-upload.service.ts) — omitting merges lets a merge-only sync + // report IN_SYNC while its merged op sits unsynced until a later cycle. + // Mirrors the rejection-handler path (operation-log-sync.service.ts:361). + return { + localWinOpsCreated: newLocalWinOps.length + mergedResolutions.length, + }; } /**