Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions lib/sync/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,13 +276,18 @@ export class SyncEngine {
console.log(`[SYNC DEBUG] Found local task: ${localTask.title} (updated: ${localTask.updatedAt})`);
console.log(`[SYNC DEBUG] Local vector clock:`, localTask.vectorClock);

// FIX #3: Only trigger conflict if local task was actually modified since last sync
const localModifiedAfterSync = config.lastSyncAt &&
new Date(localTask.updatedAt).getTime() > config.lastSyncAt;

console.log(`[SYNC DEBUG] Local modified after sync: ${localModifiedAfterSync}`);

if (localModifiedAfterSync) {
// FIX #3 (SECURITY): Check if we can safely skip conflict detection
// Only skip if we KNOW the task was NOT modified locally (i.e., lastSyncAt exists AND task is older)
// If lastSyncAt is null (reinstall/reset), we MUST check vector clocks to prevent data loss
const taskNotModifiedSinceLastSync = config.lastSyncAt &&
new Date(localTask.updatedAt).getTime() <= config.lastSyncAt;

console.log(`[SYNC DEBUG] Task not modified since last sync: ${taskNotModifiedSinceLastSync}`);
console.log(`[SYNC DEBUG] Will check vector clocks: ${!taskNotModifiedSinceLastSync}`);

// If we don't know when last sync was (null), OR task was modified after sync,
// check vector clocks to prevent data loss
if (!taskNotModifiedSinceLastSync) {
const comparison = compareVectorClocks(
localTask.vectorClock || {},
encTask.vectorClock
Expand All @@ -301,6 +306,8 @@ export class SyncEngine {
});
continue;
}
} else {
Comment on lines +306 to +309

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat remote-behind vector clocks as conflicts

The new branch always compares vector clocks when lastSyncAt is null, but the result is only used to guard on the 'concurrent' case before continuing. When compareVectorClocks returns 'b_before_a' (local clock ahead of remote), the code still falls through to "apply change" and writes the remote payload over the newer local record, merely merging clocks afterwards. This still causes data loss in the exact reinstall/reset scenario being fixed: if a user has unsynced local edits and the server returns an older version, those edits are overwritten instead of being preserved or pushed upstream. The conflict detection should also skip applying remote data (or push local changes) whenever the local clock dominates the remote clock, not just when clocks are concurrent.

Useful? React with πŸ‘Β / πŸ‘Ž.

console.log(`[SYNC DEBUG] Skipping conflict check - task confirmed unmodified since last sync`);
}
} else if (localTask) {
console.log(`[SYNC DEBUG] Local task exists but is completed, applying remote`);
Expand Down
35 changes: 31 additions & 4 deletions tests/sync/sync-conflict-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,15 +305,42 @@ describe('Sync Conflict Resolution Fixes', () => {
expect(merged).toEqual({ device1: 5, device2: 3, device3: 2 });
});

it('should handle missing lastSyncAt (first sync)', () => {
it('should handle missing lastSyncAt (first sync) - SECURITY FIX', () => {
const lastSyncAt = null;

// Task was modified at any time
const taskUpdatedAt = Date.now();

// Should not trigger conflict detection (no previous sync to compare to)
const shouldCheckConflict = lastSyncAt && taskUpdatedAt > lastSyncAt;
expect(shouldCheckConflict).toBeFalsy(); // null is falsy
// SECURITY: When lastSyncAt is null (reinstall/reset), we MUST check vector clocks
// to prevent data loss from unsynced local changes
const taskNotModifiedSinceLastSync = lastSyncAt &&
taskUpdatedAt <= lastSyncAt;

// Should be false (null && ...) = false
expect(taskNotModifiedSinceLastSync).toBeFalsy();

// Therefore, we WILL check vector clocks (security-safe behavior)
const shouldCheckVectorClocks = !taskNotModifiedSinceLastSync;
expect(shouldCheckVectorClocks).toBe(true); // MUST check to prevent data loss
});

it('should check vector clocks when lastSyncAt is missing to prevent data loss', () => {
// Scenario: User reinstalls app, has local edits, syncs
const lastSyncAt = null; // Missing after reinstall

const localClock = { device1: 5 };
const remoteClock = { device2: 3 };

// With the security fix, we check if task was NOT modified
const taskNotModifiedSinceLastSync = lastSyncAt && false; // Always false when lastSyncAt is null
expect(taskNotModifiedSinceLastSync).toBeFalsy();

// Since we can't confirm it's unmodified, we MUST check vector clocks
if (!taskNotModifiedSinceLastSync) {
const comparison = compareVectorClocks(localClock, remoteClock);
// Concurrent clocks detected - prevents data loss!
expect(comparison).toBe('concurrent');
}
});
});
});