fix(sync): count disjoint-merge ops in localWinOpsCreated

autoResolveConflictsLWW returned only newLocalWinOps.length, excluding the
synthesized merged ops appended in STEP 3b. A sync whose only conflicts were
disjoint-field merges returned 0, so the caller (immediate-upload.service.ts)
skipped the immediate re-upload and reported IN_SYNC while the merged op sat
unsynced until a later cycle.

Count mergedResolutions.length too — each merge appends exactly one pending
local op. Mirrors the rejection-handler path (operation-log-sync.service.ts).
Add a regression spec asserting a merge-only conflict returns
localWinOpsCreated: 1 (fails on the old return, passes now).

Also harden the _corruptionSuspectedConflicts WeakSet doc against a future
refactor that clones EntityConflict between detect and resolve.

Addresses review feedback on PR #8874.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Aamer Akhter 2026-07-09 10:55:44 -04:00
parent 6886f8e855
commit 23e9a56a86
2 changed files with 47 additions and 1 deletions

View file

@ -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' }));

View file

@ -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<EntityConflict>();
@ -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,
};
}
/**