diff --git a/src/commands/emails/receiving/listen.ts b/src/commands/emails/receiving/listen.ts index df6d282..a8869a8 100644 --- a/src/commands/emails/receiving/listen.ts +++ b/src/commands/emails/receiving/listen.ts @@ -8,6 +8,7 @@ import { buildHelpText } from '../../../lib/help-text'; import { errorMessage, outputError } from '../../../lib/output'; import { createSpinner } from '../../../lib/spinner'; import { isInteractive } from '../../../lib/tty'; +import { createBoundedSet } from '../../../utils/bounded-set'; const PAGE_SIZE = 100; @@ -78,7 +79,7 @@ Ctrl+C exits cleanly.`, globalOpts.quiet || jsonMode, ); - const seenIds = new Set(); + const seenIds = createBoundedSet(); let consecutiveErrors = 0; try { diff --git a/src/utils/bounded-set.ts b/src/utils/bounded-set.ts new file mode 100644 index 0000000..31c41c1 --- /dev/null +++ b/src/utils/bounded-set.ts @@ -0,0 +1,38 @@ +export type BoundedSet = { + readonly has: (value: T) => boolean; + readonly add: (value: T) => void; + readonly size: () => number; +}; + +const DEFAULT_MAX_SIZE = 10_000; + +export const createBoundedSet = ( + maxSize: number = DEFAULT_MAX_SIZE, +): BoundedSet => { + const map = new Map(); + + const evict = () => { + const oldest = map.keys().next(); + if (!oldest.done) { + map.delete(oldest.value); + } + }; + + return { + has: (value: T) => map.has(value), + + add: (value: T) => { + if (map.has(value)) { + map.delete(value); + map.set(value, true); + return; + } + if (map.size >= maxSize) { + evict(); + } + map.set(value, true); + }, + + size: () => map.size, + }; +}; diff --git a/tests/utils/bounded-set.test.ts b/tests/utils/bounded-set.test.ts new file mode 100644 index 0000000..f12e808 --- /dev/null +++ b/tests/utils/bounded-set.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { createBoundedSet } from '../../src/utils/bounded-set'; + +describe('createBoundedSet', () => { + it('tracks membership correctly', () => { + const set = createBoundedSet(5); + set.add('a'); + set.add('b'); + + expect(set.has('a')).toBe(true); + expect(set.has('b')).toBe(true); + expect(set.has('c')).toBe(false); + }); + + it('evicts oldest entries when capacity is exceeded', () => { + const set = createBoundedSet(3); + set.add('a'); + set.add('b'); + set.add('c'); + set.add('d'); + + expect(set.has('a')).toBe(false); + expect(set.has('b')).toBe(true); + expect(set.has('c')).toBe(true); + expect(set.has('d')).toBe(true); + expect(set.size()).toBe(3); + }); + + it('refreshes recently re-added entries to prevent premature eviction', () => { + const set = createBoundedSet(3); + set.add('a'); + set.add('b'); + set.add('c'); + + set.add('a'); + + set.add('d'); + + expect(set.has('a')).toBe(true); + expect(set.has('b')).toBe(false); + expect(set.has('c')).toBe(true); + expect(set.has('d')).toBe(true); + }); + + it('does not exceed the configured capacity', () => { + const set = createBoundedSet(5); + const entries = Array.from({ length: 20 }, (_, i) => i); + entries.forEach((n) => { + set.add(n); + }); + + expect(set.size()).toBe(5); + + const retained = entries.filter((n) => set.has(n)); + expect(retained).toEqual([15, 16, 17, 18, 19]); + }); + + it('handles duplicate adds without growing', () => { + const set = createBoundedSet(3); + set.add('x'); + set.add('x'); + set.add('x'); + + expect(set.size()).toBe(1); + expect(set.has('x')).toBe(true); + }); + + it('uses default capacity when none is provided', () => { + const set = createBoundedSet(); + const entries = Array.from({ length: 10_001 }, (_, i) => i); + entries.forEach((n) => { + set.add(n); + }); + + expect(set.size()).toBe(10_000); + expect(set.has(0)).toBe(false); + expect(set.has(10_000)).toBe(true); + }); +});