diff --git a/CHANGELOG.md b/CHANGELOG.md index 4754bda..7455176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `get_events` and `list_memos` now read every event in large calendars. The events sync endpoint returns the newest chunk first with a decreasing cursor, and paging stopped after the first chunk. Paging now continues while the cursor is new, in either direction. +- Event paging fails with an error instead of returning a partial list when the cursor repeats or no last chunk arrives within 200 pages, and keeps one entry per event when chunks overlap. + ## [0.5.0] - 2026-10-07 ### Added diff --git a/src/client/api.ts b/src/client/api.ts index 3648d64..1663ff2 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -75,6 +75,9 @@ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** Upper bound for event paging; at about 300 events per chunk far above real calendars. */ +const MAX_EVENT_PAGES = 200; + function todayUtcMidnight(): number { const now = new Date(); return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); @@ -292,28 +295,47 @@ export class TimeTreeAPIClient { /** * Fetch every page of an event feed. TimeTree splits large responses into chunks: while * `chunk` is true, the returned `since` is the cursor for the next page. + * + * The cursor does not always grow: the events sync endpoint returns the newest chunk + * first and reports the oldest `updated_at` of that chunk, so its cursor decreases. + * Paging therefore continues while the cursor is new, in either direction. A repeated + * cursor or too many pages is an error, never a silently truncated list: callers that + * compare listings would read the missing events as deleted. */ private async fetchEventPages( buildUrl: (since: number) => string, since: number ): Promise { - const events: Event[] = []; + // Chunks can overlap at their boundary; keep the newest version per uuid. + const byUuid = new Map(); + const visited = new Set([since]); let cursor = since; - for (;;) { + for (let page = 1; page <= MAX_EVENT_PAGES; page++) { const response = await this.send(async () => { return await this.authManager.getHttpClient().get(buildUrl(cursor)); }); const validated = EventsSyncResponseSchema.parse(response); - events.push(...validated.events); + for (const event of validated.events) { + const known = byUuid.get(event.uuid); + if (!known || (event.updated_at ?? 0) >= (known.updated_at ?? 0)) { + byUuid.set(event.uuid, event); + } + } - if (!validated.chunk || validated.since <= cursor) { - return events; + if (!validated.chunk) { + return [...byUuid.values()]; + } + if (visited.has(validated.since)) { + throw new TimeTreeAPIError(`Event paging stopped: cursor ${validated.since} repeated before the last chunk`); } - logger.debug('More events to fetch', { nextSince: validated.since, fetchedSoFar: events.length }); + logger.debug('More events to fetch', { nextSince: validated.since, fetchedSoFar: byUuid.size }); + visited.add(validated.since); cursor = validated.since; } + + throw new TimeTreeAPIError(`Event paging stopped: no last chunk after ${MAX_EVENT_PAGES} pages`); } /** Sync all active events from a calendar with automatic pagination. */ diff --git a/tests/regressions.test.mjs b/tests/regressions.test.mjs index 4639576..60688d7 100644 --- a/tests/regressions.test.mjs +++ b/tests/regressions.test.mjs @@ -361,3 +361,66 @@ test('sign-in goes through the rate limiter', async () => { await client.getCalendars(); assert.equal(limited, 2, 'expected one limited call for sign-in and one for the request'); }); + +function pagedClient(pages) { + const cursors = []; + const client = makeClient({ + get: async (url) => { + const since = Number(new URL(url).searchParams.get('since')); + cursors.push(since); + const page = pages[since]; + if (!page) throw new Error(`unexpected since=${since}`); + return page; + }, + }); + return { client, cursors }; +} + +test('event sync follows a decreasing cursor (newest chunk first)', async () => { + const { client, cursors } = pagedClient({ + 0: { chunk: true, since: 800, events: [makeEvent({ uuid: 'a', updated_at: 900 }), makeEvent({ uuid: 'b', updated_at: 800 })] }, + 800: { chunk: true, since: 600, events: [makeEvent({ uuid: 'c', updated_at: 700 }), makeEvent({ uuid: 'd', updated_at: 600 })] }, + 600: { chunk: false, since: 500, events: [makeEvent({ uuid: 'e', updated_at: 500 })] }, + }); + + const events = await client.getEventsByCalendar('123'); + + assert.deepEqual(cursors, [0, 800, 600]); + assert.deepEqual(events.map((event) => event.uuid), ['a', 'b', 'c', 'd', 'e']); +}); + +test('event paging keeps one entry per uuid when chunks overlap', async () => { + const { client } = pagedClient({ + 0: { chunk: true, since: 800, events: [makeEvent({ uuid: 'a', updated_at: 900 }), makeEvent({ uuid: 'b', updated_at: 800 })] }, + 800: { chunk: false, since: 700, events: [makeEvent({ uuid: 'b', updated_at: 800 }), makeEvent({ uuid: 'c', updated_at: 700 })] }, + }); + + const events = await client.getEventsByCalendar('123'); + + assert.deepEqual(events.map((event) => event.uuid), ['a', 'b', 'c']); +}); + +test('event paging fails instead of returning a partial list when the cursor repeats', async () => { + const { client, cursors } = pagedClient({ + 0: { chunk: true, since: 800, events: [makeEvent({ uuid: 'a', updated_at: 900 })] }, + 800: { chunk: true, since: 700, events: [makeEvent({ uuid: 'b', updated_at: 800 })] }, + 700: { chunk: true, since: 800, events: [makeEvent({ uuid: 'c', updated_at: 700 })] }, + }); + + await assert.rejects(client.getEventsByCalendar('123'), /cursor 800 repeated/); + assert.deepEqual(cursors, [0, 800, 700]); +}); + +test('event paging fails instead of returning a partial list after the page limit', async () => { + let calls = 0; + const client = makeClient({ + get: async (url) => { + calls += 1; + const since = Number(new URL(url).searchParams.get('since')); + return { chunk: true, since: since + 1, events: [makeEvent({ uuid: `e${since}` })] }; + }, + }); + + await assert.rejects(client.getEventsByCalendar('123'), /no last chunk after 200 pages/); + assert.equal(calls, 200); +});