diff --git a/desktop/windows/src/main/integrations/syncState.ts b/desktop/windows/src/main/integrations/syncState.ts index 714d7438c9..3df3f1e52a 100644 --- a/desktop/windows/src/main/integrations/syncState.ts +++ b/desktop/windows/src/main/integrations/syncState.ts @@ -3,7 +3,12 @@ import { app } from 'electron' import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs' import { join } from 'path' -import { emptySourceState, recordProcessed, type SourceState } from './syncStateLogic' +import { + emptySourceState, + normalizeSourceState, + recordProcessed, + type SourceState +} from './syncStateLogic' import type { GoogleSource } from '../../shared/types' type SyncFile = { gmail: SourceState; calendar: SourceState } @@ -17,8 +22,8 @@ function read(): SyncFile { if (existsSync(file())) { const raw = JSON.parse(readFileSync(file(), 'utf8')) as Partial return { - gmail: raw.gmail ?? emptySourceState(), - calendar: raw.calendar ?? emptySourceState() + gmail: normalizeSourceState(raw.gmail), + calendar: normalizeSourceState(raw.calendar) } } } catch { diff --git a/desktop/windows/src/main/integrations/syncStateLogic.test.ts b/desktop/windows/src/main/integrations/syncStateLogic.test.ts index 464c4ec7c3..2c19a48cc2 100644 --- a/desktop/windows/src/main/integrations/syncStateLogic.test.ts +++ b/desktop/windows/src/main/integrations/syncStateLogic.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { emptySourceState, filterNew, + normalizeSourceState, recordProcessed, MAX_PROCESSED } from './syncStateLogic' @@ -17,6 +18,41 @@ describe('filterNew', () => { }) }) +describe('normalizeSourceState', () => { + it('returns an empty state for missing or malformed persisted data', () => { + expect(normalizeSourceState(null)).toEqual(emptySourceState()) + expect(normalizeSourceState('bad')).toEqual(emptySourceState()) + expect(normalizeSourceState({ lastSyncAt: 'soon', processedIds: 'a,b' })).toEqual( + emptySourceState() + ) + }) + + it('keeps valid fields and drops invalid processed ids', () => { + expect( + normalizeSourceState({ + lastSyncAt: 1234, + processedIds: ['a', '', 42, 'b', 'a', 'c'] + }) + ).toEqual({ lastSyncAt: 1234, processedIds: ['b', 'a', 'c'] }) + }) + + it('bounds normalized ids to the newest MAX_PROCESSED entries', () => { + const ids = Array.from({ length: MAX_PROCESSED + 2 }, (_, i) => `id${i}`) + const next = normalizeSourceState({ lastSyncAt: 1, processedIds: ids }) + expect(next.processedIds.length).toBe(MAX_PROCESSED) + expect(next.processedIds[0]).toBe('id2') + expect(next.processedIds.at(-1)).toBe(`id${MAX_PROCESSED + 1}`) + }) + + it('bounds normalized ids after deduping newest entries', () => { + const ids = [...Array.from({ length: MAX_PROCESSED }, (_, i) => `id${i}`), 'tail', 'tail'] + const next = normalizeSourceState({ lastSyncAt: 1, processedIds: ids }) + expect(next.processedIds.length).toBe(MAX_PROCESSED) + expect(next.processedIds[0]).toBe('id1') + expect(next.processedIds.at(-1)).toBe('tail') + }) +}) + describe('recordProcessed', () => { it('merges new ids, dedups, and advances lastSyncAt', () => { const next = recordProcessed({ lastSyncAt: 0, processedIds: ['a'] }, ['a', 'b'], 1234) diff --git a/desktop/windows/src/main/integrations/syncStateLogic.ts b/desktop/windows/src/main/integrations/syncStateLogic.ts index 993f1c4f47..f0b1b73b88 100644 --- a/desktop/windows/src/main/integrations/syncStateLogic.ts +++ b/desktop/windows/src/main/integrations/syncStateLogic.ts @@ -12,6 +12,35 @@ export function emptySourceState(): SourceState { return { lastSyncAt: 0, processedIds: [] } } +export function normalizeSourceState(value: unknown): SourceState { + if (!value || typeof value !== 'object') return emptySourceState() + const raw = value as { lastSyncAt?: unknown; processedIds?: unknown } + const lastSyncAt = + typeof raw.lastSyncAt === 'number' && Number.isFinite(raw.lastSyncAt) && raw.lastSyncAt > 0 + ? raw.lastSyncAt + : 0 + + if (!Array.isArray(raw.processedIds)) { + return { lastSyncAt, processedIds: [] } + } + + const seen = new Set() + const processedIds: string[] = [] + for (let i = raw.processedIds.length - 1; i >= 0; i--) { + const id = raw.processedIds[i] + if (typeof id !== 'string' || !id || seen.has(id)) continue + processedIds.unshift(id) + seen.add(id) + if (seen.size >= MAX_PROCESSED) break + } + + const bounded = + processedIds.length > MAX_PROCESSED + ? processedIds.slice(processedIds.length - MAX_PROCESSED) + : processedIds + return { lastSyncAt, processedIds: bounded } +} + /** Items whose id is NOT already in the processed set. */ export function filterNew(items: T[], processedIds: string[]): T[] { const seen = new Set(processedIds)