Skip to content

Commit 3454575

Browse files
committed
fix(crunchbase): honor card paging limits and cursor exclusivity
- Cap a card page at the documented 100-item maximum instead of Search's 1000, which the shared Limit field made easy to carry over - Always request the card's identifier so a narrowed cardFieldIds cannot return a full page with a null cursor and stall a paging loop - Reject the mutually-exclusive afterId/beforeId pair on the card and deleted-entity endpoints, not just on search - Report an unexpected card shape as empty rather than wrapping the envelope as a one-row page
1 parent a9a4438 commit 3454575

7 files changed

Lines changed: 122 additions & 17 deletions

File tree

apps/docs/content/docs/en/integrations/crunchbase.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,9 @@ Page through one related-entity card of a Crunchbase entity — an investor's in
293293
| `collection` | string | Yes | Collection the entity belongs to. One of: acquisitions, addresses, categories, category_groups, degrees, event_appearances, events, funding_rounds, funds, investments, ipos, jobs, market_insights, micro_categories, organizations, ownerships, people. |
294294
| `entityId` | string | Yes | Entity permalink or UUID |
295295
| `cardId` | string | Yes | Card to page through, e.g. "participated_investments" on a person, "founders" on an organization, or "investors" on a funding round. Valid ids differ per collection. |
296-
| `cardFieldIds` | json | No | Fields to return on each card item, e.g. \["identifier","announced_on","money_raised"\] |
296+
| `cardFieldIds` | json | No | Fields to return on each card item, e.g. \["identifier","announced_on","money_raised"\]. The identifier is always requested alongside these, because the next-page cursor is read from it. |
297297
| `cardOrder` | string | No | Sort expression for the card, e.g. "funding_round_money_raised desc" |
298-
| `limit` | number | No | Card items to return per page |
298+
| `limit` | number | No | Card items to return per page, 1-100 |
299299
| `afterId` | string | No | UUID of the last card item on the current page, to fetch the next page |
300300
| `beforeId` | string | No | UUID of the first card item on the current page, to fetch the previous page |
301301

apps/sim/blocks/blocks/crunchbase.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ export const CrunchbaseBlock: BlockConfig<CrunchbaseResponse> = {
303303
title: 'Limit',
304304
type: 'short-input',
305305
mode: 'advanced',
306-
placeholder: 'Search: 1-1000 (default 100). Autocomplete: 1-25 (default 10)',
306+
placeholder: 'Search: 1-1000 (default 100). Card: 1-100. Autocomplete: 1-25 (default 10)',
307307
condition: { field: 'operation', value: LIMITED_OPERATIONS },
308308
},
309309
{

apps/sim/tools/crunchbase/crunchbase.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,56 @@ describe('crunchbase request building', () => {
137137
expect(url).toContain('after_id=cursor-1')
138138
})
139139

140+
it('caps a card page at the documented 100-item maximum', () => {
141+
const url = buildUrl(crunchbaseGetEntityCardTool, {
142+
apiKey: 'k',
143+
collection: 'organizations',
144+
entityId: 'sequoia-capital',
145+
cardId: 'participated_investments',
146+
limit: 1000,
147+
})
148+
/* Substring-matching "limit=100" would also pass on "limit=1000" — the exact
149+
parameter value is the only assertion that can actually fail here. */
150+
expect(new URL(url).searchParams.get('limit')).toBe('100')
151+
})
152+
153+
it('keeps the cursor field when cardFieldIds would have dropped it', () => {
154+
const narrowed = buildUrl(crunchbaseGetEntityCardTool, {
155+
apiKey: 'k',
156+
collection: 'organizations',
157+
entityId: 'sequoia-capital',
158+
cardId: 'participated_investments',
159+
cardFieldIds: '["announced_on"]',
160+
})
161+
expect(narrowed).toContain('card_field_ids=announced_on%2Cidentifier')
162+
163+
const alreadyPresent = buildUrl(crunchbaseGetEntityCardTool, {
164+
apiKey: 'k',
165+
collection: 'organizations',
166+
entityId: 'sequoia-capital',
167+
cardId: 'participated_investments',
168+
cardFieldIds: '["uuid","announced_on"]',
169+
})
170+
expect(alreadyPresent).toContain('card_field_ids=uuid%2Cannounced_on')
171+
})
172+
173+
it('rejects both cursors on every paged endpoint, not just search', () => {
174+
expect(() =>
175+
buildUrl(crunchbaseGetEntityCardTool, {
176+
apiKey: 'k',
177+
collection: 'organizations',
178+
entityId: 'sequoia-capital',
179+
cardId: 'founders',
180+
afterId: 'a',
181+
beforeId: 'b',
182+
})
183+
).toThrow(/either "afterId" or "beforeId"/)
184+
185+
expect(() =>
186+
buildUrl(crunchbaseListDeletedEntitiesTool, { apiKey: 'k', afterId: 'a', beforeId: 'b' })
187+
).toThrow(/either "afterId" or "beforeId"/)
188+
})
189+
140190
it('scopes the deleted feed by path when one collection is chosen', () => {
141191
expect(
142192
buildUrl(crunchbaseListDeletedEntitiesTool, { apiKey: 'k', collection: 'organizations' })
@@ -268,6 +318,21 @@ describe('crunchbase response mapping', () => {
268318
expect(result.output.nextAfterId).toBe('p1')
269319
})
270320

321+
it('reports an undocumented card shape as empty rather than inventing a row', async () => {
322+
const result = await crunchbaseGetEntityCardTool.transformResponse!(
323+
jsonResponse({ cards: { founders: { items: [{ uuid: 'p1' }], paging: {} } } }),
324+
{
325+
apiKey: 'k',
326+
collection: 'organizations',
327+
entityId: 'tesla-motors',
328+
cardId: 'founders',
329+
} as never
330+
)
331+
332+
expect(result.output.items).toEqual([])
333+
expect(result.output.nextAfterId).toBeNull()
334+
})
335+
271336
it('passes the fields-metadata CSV through verbatim', async () => {
272337
const csv = 'collection_id,field_id,type\norganizations,name,text\n'
273338
const result = await crunchbaseGetFieldsMetadataTool.transformResponse!(

apps/sim/tools/crunchbase/get_entity_card.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { CrunchbaseProperties } from '@/tools/crunchbase/types'
22
import {
33
assertCollection,
4+
assertSingleCursor,
5+
CARD_LIMIT_MAX,
46
CRUNCHBASE_API_BASE,
57
CRUNCHBASE_CARD_COLLECTIONS,
68
clampLimit,
79
crunchbaseError,
810
crunchbaseHeaders,
911
parseIdListParam,
1012
readJson,
11-
SEARCH_LIMIT_MAX,
1213
} from '@/tools/crunchbase/utils'
1314
import { ErrorExtractorId } from '@/tools/error-extractors'
1415
import type { ToolConfig, ToolResponse } from '@/tools/types'
@@ -33,6 +34,20 @@ interface CrunchbaseGetEntityCardResponse extends ToolResponse {
3334
}
3435
}
3536

37+
/**
38+
* Guarantees the page carries the field its cursor is read from.
39+
*
40+
* `nextAfterId` comes off the last item's `uuid` / `identifier.uuid`, so a caller
41+
* narrowing `card_field_ids` to, say, `["announced_on"]` would get a full page
42+
* and a null cursor — and a paging loop would stop after the first page with
43+
* rows still unread.
44+
*/
45+
function withCursorField(cardFieldIds: string[] | undefined): string[] | undefined {
46+
if (!cardFieldIds?.length) return cardFieldIds
47+
if (cardFieldIds.includes('identifier') || cardFieldIds.includes('uuid')) return cardFieldIds
48+
return [...cardFieldIds, 'identifier']
49+
}
50+
3651
export const crunchbaseGetEntityCardTool: ToolConfig<
3752
CrunchbaseGetEntityCardParams,
3853
CrunchbaseGetEntityCardResponse
@@ -76,7 +91,7 @@ export const crunchbaseGetEntityCardTool: ToolConfig<
7691
required: false,
7792
visibility: 'user-or-llm',
7893
description:
79-
'Fields to return on each card item, e.g. ["identifier","announced_on","money_raised"]',
94+
'Fields to return on each card item, e.g. ["identifier","announced_on","money_raised"]. The identifier is always requested alongside these, because the next-page cursor is read from it.',
8095
},
8196
cardOrder: {
8297
type: 'string',
@@ -88,7 +103,7 @@ export const crunchbaseGetEntityCardTool: ToolConfig<
88103
type: 'number',
89104
required: false,
90105
visibility: 'user-or-llm',
91-
description: 'Card items to return per page',
106+
description: 'Card items to return per page, 1-100',
92107
},
93108
afterId: {
94109
type: 'string',
@@ -116,11 +131,13 @@ export const crunchbaseGetEntityCardTool: ToolConfig<
116131
const cardId = params.cardId?.trim()
117132
if (!cardId) throw new Error('Crunchbase "cardId" is required')
118133

134+
assertSingleCursor(params.afterId, params.beforeId)
135+
119136
const search = new URLSearchParams()
120-
const cardFieldIds = parseIdListParam(params.cardFieldIds, 'cardFieldIds')
137+
const cardFieldIds = withCursorField(parseIdListParam(params.cardFieldIds, 'cardFieldIds'))
121138
if (cardFieldIds?.length) search.set('card_field_ids', cardFieldIds.join(','))
122139
if (params.cardOrder) search.set('order', params.cardOrder)
123-
const limit = clampLimit(params.limit, SEARCH_LIMIT_MAX)
140+
const limit = clampLimit(params.limit, CARD_LIMIT_MAX)
124141
if (limit !== undefined) search.set('limit', String(limit))
125142
if (params.afterId) search.set('after_id', params.afterId)
126143
if (params.beforeId) search.set('before_id', params.beforeId)
@@ -144,11 +161,11 @@ export const crunchbaseGetEntityCardTool: ToolConfig<
144161
requested card id rather than at the top level — keyed by the same trimmed
145162
id the URL used, or a pasted " founders " would read back as empty. */
146163
const card = data.cards?.[params?.cardId?.trim() ?? '']
147-
const items = Array.isArray(card)
148-
? (card as CrunchbaseProperties[])
149-
: card && typeof card === 'object'
150-
? [card as CrunchbaseProperties]
151-
: []
164+
165+
/* Every card this endpoint serves is typed as an array of entities. Wrapping
166+
a non-array as a single item would invent a one-row page out of a shape we
167+
do not understand, so an unexpected value reports as empty instead. */
168+
const items = Array.isArray(card) ? (card as CrunchbaseProperties[]) : []
152169
/* A card item carries its uuid at the top level only when `card_field_ids`
153170
asked for it; otherwise the identifier object is the one place it lives. */
154171
const last = items[items.length - 1]

apps/sim/tools/crunchbase/list_deleted_entities.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
appendCsvParam,
44
assertCollection,
55
assertCollections,
6+
assertSingleCursor,
67
CRUNCHBASE_API_BASE,
78
CRUNCHBASE_DELETED_COLLECTIONS,
89
clampLimit,
@@ -97,6 +98,8 @@ export const crunchbaseListDeletedEntitiesTool: ToolConfig<
9798

9899
request: {
99100
url: (params) => {
101+
assertSingleCursor(params.afterId, params.beforeId)
102+
100103
const search = new URLSearchParams()
101104
const scoped = params.collection?.trim()
102105

apps/sim/tools/crunchbase/utils.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,28 @@ export function parseIdListParam(value: unknown, paramName: string): string[] |
292292
return parsed?.map((entry) => String(entry))
293293
}
294294

295+
/**
296+
* `limit` bound for a single card page.
297+
*
298+
* A card returns at most 100 items, and the Limit subblock is shared with Search
299+
* (max 1000), so a value carried over from a search would otherwise go out on
300+
* the wire and be rejected.
301+
*/
302+
export const CARD_LIMIT_MAX = 100
303+
304+
/**
305+
* Rejects the cursor pair Crunchbase documents as mutually exclusive.
306+
*
307+
* `after_id` "may not be provided simultaneously with before_id" on every paged
308+
* endpoint, and the block shares one After ID / Before ID pair across searches,
309+
* card pages, and the deleted feed — so a leftover value really can arrive here.
310+
*/
311+
export function assertSingleCursor(afterId?: string, beforeId?: string): void {
312+
if (afterId && beforeId) {
313+
throw new Error('Crunchbase accepts either "afterId" or "beforeId", not both')
314+
}
315+
}
316+
295317
/** Coerces a numeric param and clamps it into the endpoint's documented range. */
296318
export function clampLimit(value: unknown, max: number, fallback?: number): number | undefined {
297319
if (value === undefined || value === null || value === '') return fallback
@@ -457,9 +479,7 @@ export function buildSearchBody(
457479
const order = normalizeOrder(params.order)
458480
const limit = clampLimit(params.limit, SEARCH_LIMIT_MAX, SEARCH_LIMIT_DEFAULT)
459481

460-
if (params.afterId && params.beforeId) {
461-
throw new Error('Crunchbase accepts either "afterId" or "beforeId", not both')
462-
}
482+
assertSingleCursor(params.afterId, params.beforeId)
463483

464484
const resolvedFieldIds = fieldIds?.length ? fieldIds : [...(defaultFieldIds ?? [])]
465485
if (resolvedFieldIds.length === 0) {

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)