Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document the new paging behavior in every user guide

This announces a user-visible change to get_events and list_memos—including complete large-calendar results and new paging failures—but the commit leaves README.md, README.ko.md, README.ja.md, and COMMANDS.md unchanged. Add matching descriptions (including the shared pager's effect on get_updated_events) so every supported guide documents the new result and failure contract as required by the repository review rule.

AGENTS.md reference: AGENTS.md:L171-L171

Useful? React with 👍 / 👎.

- 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
Expand Down
34 changes: 28 additions & 6 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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<Event[]> {
const events: Event[] = [];
// Chunks can overlap at their boundary; keep the newest version per uuid.
const byUuid = new Map<string, Event>();
const visited = new Set<number>([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<EventsSyncResponse>(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. */
Expand Down
63 changes: 63 additions & 0 deletions tests/regressions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading