From 5bab528fbf3ed645b455e15dad9a406444cf8497 Mon Sep 17 00:00:00 2001 From: Amir Date: Fri, 10 Jul 2026 17:35:10 +0200 Subject: [PATCH 1/2] feat: add pagination args to getConversations olderThan (Date), beforeId, and limit page through /conversations/get. Paired olderThan+beforeId forms the strict compound (last_active, id) boundary added in twist-new-backend#690. The Date converts to older_than_ts in the client because the transport's generic snake-casing turns a Date into an empty object. Co-Authored-By: Claude Fable 5 --- src/clients/conversations-client.test.ts | 63 ++++++++++++++++++++++++ src/clients/conversations-client.ts | 30 +++++++++-- src/types/requests.ts | 3 ++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 src/clients/conversations-client.test.ts diff --git a/src/clients/conversations-client.test.ts b/src/clients/conversations-client.test.ts new file mode 100644 index 0000000..9256aa0 --- /dev/null +++ b/src/clients/conversations-client.test.ts @@ -0,0 +1,63 @@ +import { http, HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' + +import { CommsApi } from '../comms-api' +import { server } from '../testUtils/msw-setup' +import { + TEST_API_BASE_URL as BASE, + TEST_API_TOKEN, + TEST_CONVERSATION_ID, +} from '../testUtils/test-defaults' + +// Pins the wire shape of the `conversations/get` pagination args. `olderThan` +// is a `Date` that the client must convert itself — the transport's generic +// snake-casing would turn a `Date` into an empty object. + +describe('ConversationsClient — wire serialization', () => { + async function captureGetParams( + args: Parameters[0], + ) { + const capturedUrls: URL[] = [] + server.use( + http.get(`${BASE}/conversations/get`, ({ request }) => { + capturedUrls.push(new URL(request.url)) + return HttpResponse.json([]) + }), + ) + + const api = new CommsApi(TEST_API_TOKEN) + await api.conversations.getConversations(args) + + expect(capturedUrls).toHaveLength(1) + return (capturedUrls[0] as URL).searchParams + } + + it('getConversations sends the compound cursor as older_than_ts / before_id', async () => { + const olderThan = new Date('2026-06-25T15:53:53Z') + const params = await captureGetParams({ + workspaceId: 123, + archived: false, + olderThan, + beforeId: TEST_CONVERSATION_ID, + limit: 500, + }) + + expect(params.get('workspace_id')).toBe('123') + expect(params.get('archived')).toBe('false') + expect(params.get('older_than_ts')).toBe(String(Math.floor(olderThan.getTime() / 1000))) + // The Date arg must not leak through the generic snake-casing. + expect(params.has('older_than')).toBe(false) + expect(params.get('before_id')).toBe(TEST_CONVERSATION_ID) + expect(params.get('limit')).toBe('500') + }) + + it('getConversations keeps the minimal call shape unchanged', async () => { + const params = await captureGetParams({ workspaceId: 123 }) + + expect(params.get('workspace_id')).toBe('123') + expect(params.has('archived')).toBe(false) + expect(params.has('older_than_ts')).toBe(false) + expect(params.has('before_id')).toBe(false) + expect(params.has('limit')).toBe(false) + }) +}) diff --git a/src/clients/conversations-client.ts b/src/clients/conversations-client.ts index e762a9c..e931e45 100644 --- a/src/clients/conversations-client.ts +++ b/src/clients/conversations-client.ts @@ -47,26 +47,46 @@ export class ConversationsClient extends BaseClient { : ConversationListSchema /** - * Gets all conversations for a workspace. + * Gets a page of conversations for a workspace, newest activity first. + * The server returns at most 500 rows per request (20 by default); pass + * the last row's `lastActive`/`id` as `olderThan`/`beforeId` to fetch + * the next page. Paired, they form a strict compound boundary, so pages + * never repeat rows. Omitting `archived` returns active and archived + * conversations mixed. * * @param args - The arguments for getting conversations. * @param args.workspaceId - The workspace ID. - * @param args.archived - Optional flag to include archived conversations. + * @param args.archived - Optional flag to filter archived (true) or active (false) conversations. + * @param args.olderThan - Optional date to get conversations last active before. + * @param args.beforeId - Optional conversation id paired with olderThan for compound pagination. + * @param args.limit - Optional page size (server default 20, max 500). * @returns An array of conversation objects. * * @example * ```typescript - * const conversations = await api.conversations.getConversations({ workspaceId: 123 }) - * conversations.forEach(c => console.log(c.title)) + * const page = await api.conversations.getConversations({ workspaceId: 123, limit: 500 }) + * const last = page[page.length - 1] + * const nextPage = await api.conversations.getConversations({ + * workspaceId: 123, + * limit: 500, + * olderThan: last.lastActive, + * beforeId: last.id, + * }) * ``` */ getConversations(args: GetConversationsArgs): Promise { + const { olderThan, ...rest } = args + const params: Record = { ...rest } + // The generic snake-casing walks objects, so a Date must be + // converted before it reaches the transport. + if (olderThan) params.olderThanTs = Math.floor(olderThan.getTime() / 1000) + return request({ httpMethod: 'GET', baseUri: this.getBaseUri(), relativePath: `${ENDPOINT_CONVERSATIONS}/get`, apiToken: this.apiToken, - payload: args, + payload: params, customFetch: this.customFetch, }).then((response) => this.conversationListSchema.parse(response.data)) } diff --git a/src/types/requests.ts b/src/types/requests.ts index 1648489..9a41a39 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -143,6 +143,9 @@ export type GetCommentsArgs = z.infer export const GetConversationsArgsSchema = z.object({ workspaceId: z.number(), archived: z.boolean().nullable().optional(), + olderThan: z.date().nullable().optional(), + beforeId: z.string().nullable().optional(), + limit: z.number().nullable().optional(), }) export type GetConversationsArgs = z.infer From d1a9e0cb9e3540f7aad06e053d496f1a3206e435 Mon Sep 17 00:00:00 2001 From: Amir Date: Fri, 10 Jul 2026 18:33:18 +0200 Subject: [PATCH 2/2] fix: address review findings on getConversations Explicit field-by-field params (matching getThreads/getComments) so a future Date field cannot silently hit the generic snake-casing; the doc example no longer dereferences an empty page; beforeId's standalone id-order mode is documented. Co-Authored-By: Claude Fable 5 --- src/clients/conversations-client.ts | 32 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/clients/conversations-client.ts b/src/clients/conversations-client.ts index e931e45..90c5d8e 100644 --- a/src/clients/conversations-client.ts +++ b/src/clients/conversations-client.ts @@ -58,28 +58,34 @@ export class ConversationsClient extends BaseClient { * @param args.workspaceId - The workspace ID. * @param args.archived - Optional flag to filter archived (true) or active (false) conversations. * @param args.olderThan - Optional date to get conversations last active before. - * @param args.beforeId - Optional conversation id paired with olderThan for compound pagination. + * @param args.beforeId - Optional conversation id. Paired with olderThan it forms the + * strict compound cursor; alone it pages by conversation id order instead. * @param args.limit - Optional page size (server default 20, max 500). * @returns An array of conversation objects. * * @example * ```typescript * const page = await api.conversations.getConversations({ workspaceId: 123, limit: 500 }) - * const last = page[page.length - 1] - * const nextPage = await api.conversations.getConversations({ - * workspaceId: 123, - * limit: 500, - * olderThan: last.lastActive, - * beforeId: last.id, - * }) + * const last = page.at(-1) + * const nextPage = last + * ? await api.conversations.getConversations({ + * workspaceId: 123, + * limit: 500, + * olderThan: last.lastActive, + * beforeId: last.id, + * }) + * : [] * ``` */ getConversations(args: GetConversationsArgs): Promise { - const { olderThan, ...rest } = args - const params: Record = { ...rest } - // The generic snake-casing walks objects, so a Date must be - // converted before it reaches the transport. - if (olderThan) params.olderThanTs = Math.floor(olderThan.getTime() / 1000) + // Fields are picked explicitly (matching getThreads/getComments) so a + // future Date field can't silently reach the generic snake-casing, + // which would turn it into an empty object on the wire. + const params: Record = { workspaceId: args.workspaceId } + if (args.archived != null) params.archived = args.archived + if (args.olderThan) params.olderThanTs = Math.floor(args.olderThan.getTime() / 1000) + if (args.beforeId != null) params.beforeId = args.beforeId + if (args.limit != null) params.limit = args.limit return request({ httpMethod: 'GET',