mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
6886f8e855
commit
23e9a56a86
2 changed files with 47 additions and 1 deletions
|
|
@ -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' }));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue