From 1406349a78660ed51c0c688cc4c8cd87ec1acf8f Mon Sep 17 00:00:00 2001 From: rami-monday Date: Tue, 26 May 2026 16:48:34 +0300 Subject: [PATCH] Add agent helpers and LLM context rules for agentic SDK usage Adds utility modules for AI agents and automation scripts: - ColumnValues: typed builders for all common column value JSON formats - paginateItems/getAllItems: async cursor-based pagination - withRetry: exponential backoff on 429/complexity errors - getComplexityBudget/getComplexityFromResponse: budget monitoring Adds three new LLM context rules: - column-value-formats.mdc: JSON format reference for all column types - agentic-patterns.mdc: pagination, search, bulk update, error recovery - rate-limiting.mdc: complexity budget and rate limit handling guide Co-Authored-By: Claude Opus 4.6 --- llm-context/rules/agentic-patterns.mdc | 211 ++++++++++++++++++ llm-context/rules/column-value-formats.mdc | 171 ++++++++++++++ llm-context/rules/rate-limiting.mdc | 118 ++++++++++ packages/api/README.md | 60 +++++ packages/api/lib/helpers/column-values.ts | 108 +++++++++ packages/api/lib/helpers/index.ts | 7 + packages/api/lib/helpers/paginate.ts | 97 ++++++++ packages/api/lib/helpers/rate-limit.ts | 52 +++++ packages/api/lib/helpers/retry.ts | 89 ++++++++ packages/api/lib/index.ts | 1 + .../api/tests/helpers/column-values.test.ts | 176 +++++++++++++++ packages/api/tests/helpers/rate-limit.test.ts | 32 +++ packages/api/tests/helpers/retry.test.ts | 75 +++++++ 13 files changed, 1197 insertions(+) create mode 100644 llm-context/rules/agentic-patterns.mdc create mode 100644 llm-context/rules/column-value-formats.mdc create mode 100644 llm-context/rules/rate-limiting.mdc create mode 100644 packages/api/lib/helpers/column-values.ts create mode 100644 packages/api/lib/helpers/index.ts create mode 100644 packages/api/lib/helpers/paginate.ts create mode 100644 packages/api/lib/helpers/rate-limit.ts create mode 100644 packages/api/lib/helpers/retry.ts create mode 100644 packages/api/tests/helpers/column-values.test.ts create mode 100644 packages/api/tests/helpers/rate-limit.test.ts create mode 100644 packages/api/tests/helpers/retry.test.ts diff --git a/llm-context/rules/agentic-patterns.mdc b/llm-context/rules/agentic-patterns.mdc new file mode 100644 index 0000000..babab0d --- /dev/null +++ b/llm-context/rules/agentic-patterns.mdc @@ -0,0 +1,211 @@ +--- +description: Patterns for AI agents interacting with the monday.com API. Use when building agentic workflows, automation scripts, or LLM-driven tools that read/write monday.com data. +globs: +alwaysApply: false +--- +# Agentic Patterns for Monday.com API + +## Pagination — Reading All Items from a Board + +Never assume all items fit in one response. Use cursor-based pagination: + +```typescript +import { ApiClient, paginateItems } from '@mondaydotcomorg/api'; + +const client = new ApiClient({ token: process.env.MONDAY_API_TOKEN }); + +// Stream items page by page (memory-efficient) +for await (const page of paginateItems(client, "board_id_here")) { + for (const item of page) { + console.log(item.id, item.name, item.column_values); + } +} +``` + +Or collect all at once (small boards only): + +```typescript +import { getAllItems } from '@mondaydotcomorg/api'; + +const items = await getAllItems(client, "board_id_here", { limit: 200 }); +``` + +### Manual pagination pattern (when you need custom fields): + +```typescript +let cursor: string | null = null; +const allItems: any[] = []; + +// First page +const first = await client.request(` + query ($boardId: [ID!]!) { + boards(ids: $boardId) { + items_page(limit: 100) { + cursor + items { id name column_values { id text value } } + } + } + } +`, { boardId: ["your_board_id"] }); + +allItems.push(...first.boards[0].items_page.items); +cursor = first.boards[0].items_page.cursor; + +// Subsequent pages +while (cursor) { + const next = await client.request(` + query ($cursor: String!) { + next_items_page(cursor: $cursor, limit: 100) { + cursor + items { id name column_values { id text value } } + } + } + `, { cursor }); + allItems.push(...next.next_items_page.items); + cursor = next.next_items_page.cursor; +} +``` + +## Searching Items by Column Values + +Use `items_page_by_column_values` to find items matching specific criteria: + +```typescript +const result = await client.request(` + query ($boardId: ID!, $columns: [ItemsPageByColumnValuesQuery!]) { + items_page_by_column_values(board_id: $boardId, columns: $columns, limit: 50) { + cursor + items { id name column_values { id text value } } + } + } +`, { + boardId: "your_board_id", + columns: [{ column_id: "status", column_values: ["Done", "Working on it"] }] +}); +``` + +## Bulk Updates — Updating Multiple Items + +When updating many items, batch your mutations and respect rate limits: + +```typescript +import { withRetry, ColumnValues } from '@mondaydotcomorg/api'; + +const itemsToUpdate = [ + { id: "111", status: "Done" }, + { id: "222", status: "Working on it" }, +]; + +for (const item of itemsToUpdate) { + await withRetry(() => + client.request(` + mutation ($boardId: ID!, $itemId: ID!, $value: JSON!) { + change_column_value(board_id: $boardId, item_id: $itemId, column_id: "status", value: $value) { id } + } + `, { + boardId: "your_board_id", + itemId: item.id, + value: ColumnValues.status(item.status), + }) + ); +} +``` + +## Creating Items with Column Values + +```typescript +const newItem = await client.request(` + mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) { + create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { + id + name + } + } +`, { + boardId: "your_board_id", + itemName: "New Task", + columnValues: JSON.stringify({ + status: { label: "Working on it" }, + date: { date: "2024-06-15" }, + person: { personsAndTeams: [{ id: 12345, kind: "person" }] }, + }), +}); +``` + +## Error Recovery with Retry + +Always wrap API calls with retry logic in agentic workflows: + +```typescript +import { withRetry } from '@mondaydotcomorg/api'; + +const result = await withRetry( + () => client.request(query, variables), + { + maxRetries: 3, + initialDelayMs: 1000, + retryOnRateLimit: true, + retryOnComplexity: true, + } +); +``` + +## Monitoring Complexity Budget + +```typescript +import { getComplexityBudget } from '@mondaydotcomorg/api'; + +const budget = await getComplexityBudget(client); +if (budget && budget.after < 500) { + // Approaching limit — wait for reset + await new Promise(r => setTimeout(r, budget.resetInSeconds * 1000)); +} +``` + +## Common Agent Workflow: Read → Decide → Act + +A typical agentic pattern for monday.com: + +```typescript +import { ApiClient, paginateItems, withRetry, ColumnValues } from '@mondaydotcomorg/api'; + +const client = new ApiClient({ token: process.env.MONDAY_API_TOKEN }); +const BOARD_ID = "your_board_id"; + +// 1. Read: gather current state +const allItems = []; +for await (const page of paginateItems(client, BOARD_ID)) { + allItems.push(...page); +} + +// 2. Decide: filter items that need action +const overdueItems = allItems.filter(item => { + const dateCol = item.column_values.find(cv => cv.id === "date"); + return dateCol?.text && new Date(dateCol.text) < new Date(); +}); + +// 3. Act: update each overdue item's status +for (const item of overdueItems) { + await withRetry(() => + client.request(` + mutation ($boardId: ID!, $itemId: ID!, $value: JSON!) { + change_column_value(board_id: $boardId, item_id: $itemId, column_id: "status", value: $value) { id } + } + `, { + boardId: BOARD_ID, + itemId: item.id, + value: ColumnValues.status("Stuck"), + }) + ); +} +``` + +## Best Practices for Agents + +1. **Always paginate** — boards can have thousands of items +2. **Use withRetry** — rate limits and complexity budgets are real +3. **Request only needed fields** — reduces complexity cost +4. **Use column IDs not titles** — query the board's columns first if unknown +5. **Batch reads, sequential writes** — read all data first, then mutate +6. **Check complexity before large operations** — use `getComplexityBudget()` +7. **Prefer `change_multiple_column_values`** over multiple `change_column_value` calls for the same item diff --git a/llm-context/rules/column-value-formats.mdc b/llm-context/rules/column-value-formats.mdc new file mode 100644 index 0000000..3654990 --- /dev/null +++ b/llm-context/rules/column-value-formats.mdc @@ -0,0 +1,171 @@ +--- +description: Reference for monday.com column value JSON formats. Use when writing mutations that set or change column values (change_column_value, change_multiple_column_values, create_item with column_values). +globs: +alwaysApply: false +--- +# Monday.com Column Value JSON Formats + +When using `change_column_value`, `change_multiple_column_values`, or `create_item` with `column_values`, the value must be a JSON-serialized string matching the column type's expected format. + +## Quick Reference + +### Status Column +```json +{"label": "Done"} +``` +Or by index: +```json +{"index": 1} +``` + +### Text Column +```json +"Hello world" +``` +Note: Plain string, not wrapped in an object. + +### Numbers Column +```json +"42" +``` +Note: Plain string of the number. + +### Date Column +```json +{"date": "2024-06-15"} +``` +With time: +```json +{"date": "2024-06-15", "time": "14:30:00"} +``` + +### People Column +```json +{"personsAndTeams": [{"id": 12345, "kind": "person"}, {"id": 67, "kind": "team"}]} +``` + +### Dropdown Column +By labels (creates labels if `create_labels_if_missing: true`): +```json +{"labels": ["Option A", "Option B"]} +``` +By IDs: +```json +{"ids": [1, 2, 3]} +``` + +### Timeline Column +```json +{"from": "2024-01-01", "to": "2024-01-31"} +``` + +### Checkbox Column +```json +{"checked": "true"} +``` + +### Long Text Column +```json +{"text": "Multi-line content here"} +``` + +### Email Column +```json +{"email": "user@example.com", "text": "Display Name"} +``` + +### Phone Column +```json +{"phone": "+1234567890", "countryShortName": "US"} +``` + +### Link Column +```json +{"url": "https://example.com", "text": "Click here"} +``` + +### Rating Column +```json +{"rating": 4} +``` + +### Hour Column +```json +{"hour": 14, "minute": 30} +``` + +### Location Column +```json +{"lat": 40.7128, "lng": -74.0060, "address": "New York, NY"} +``` + +### Country Column +```json +{"countryCode": "US", "countryName": "United States"} +``` + +### Tags Column +```json +{"tag_ids": [123, 456]} +``` + +### Week Column +```json +{"week": {"startDate": "2024-06-10", "endDate": "2024-06-16"}} +``` + +### Board Relation (Connect Boards) Column +```json +{"item_ids": [123456, 789012]} +``` + +### Clear Any Column +```json +null +``` + +## Using the SDK Column Value Builders + +If you're using `@mondaydotcomorg/api`, import the `ColumnValues` helper: + +```typescript +import { ApiClient, ColumnValues } from '@mondaydotcomorg/api'; + +const client = new ApiClient({ token: process.env.MONDAY_API_TOKEN }); + +// Change a single column +await client.request( + `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { + change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } + }`, + { + boardId: "123", + itemId: "456", + columnId: "status", + value: ColumnValues.status("Done"), + } +); + +// Change multiple columns at once +await client.request( + `mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) { + change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id } + }`, + { + boardId: "123", + itemId: "456", + columnValues: JSON.stringify({ + status: { label: "Working on it" }, + date: { date: "2024-06-15" }, + text: "Updated by agent", + }), + } +); +``` + +## Important Notes + +1. **JSON serialization**: The `value` parameter in `change_column_value` expects a JSON *string*. Use `JSON.stringify()` on the object. +2. **column_values in create_item / change_multiple_column_values**: This is also a JSON *string* — a serialized object where keys are column IDs and values are the typed objects above (without extra stringification per-field). +3. **Column IDs**: Use the actual column ID (e.g., `"status"`, `"date4"`, `"text0"`), not the column title. Query the board's columns first if unsure. +4. **create_labels_if_missing**: Set to `true` in the mutation if you want status/dropdown labels auto-created when they don't exist. diff --git a/llm-context/rules/rate-limiting.mdc b/llm-context/rules/rate-limiting.mdc new file mode 100644 index 0000000..daaca4e --- /dev/null +++ b/llm-context/rules/rate-limiting.mdc @@ -0,0 +1,118 @@ +--- +description: Monday.com API rate limiting and complexity budget guide. Use when building apps or agents that make many API calls and need to handle rate limits correctly. +globs: +alwaysApply: false +--- +# Rate Limiting & Complexity Budget + +## How Monday.com Rate Limiting Works + +The monday.com API uses a **complexity-based** rate limiting system, not simple request counting. + +### Complexity Budget +- Each account has a complexity budget that regenerates over time +- Every query/mutation costs a certain amount of complexity +- When the budget is exhausted, requests return a `complexityBudgetExhausted` error +- The budget resets gradually (not all at once) + +### Checking Your Budget + +Add `complexity` to any query to see current budget: + +```graphql +query { + complexity { before after query reset_in_x_seconds } + boards(ids: ["123"]) { name } +} +``` + +Or use the SDK helper: + +```typescript +import { getComplexityBudget } from '@mondaydotcomorg/api'; + +const budget = await getComplexityBudget(client); +// budget.before — budget before this query +// budget.after — budget remaining after this query +// budget.query — cost of this specific query +// budget.resetInSeconds — seconds until full budget reset +``` + +### Reducing Complexity Cost + +1. **Request only fields you need** — each field adds cost +2. **Use `limit` parameters** — smaller pages = less complexity +3. **Avoid nested relations** — `items { subitems { column_values } }` is expensive +4. **Use `items_page` with cursor** instead of loading all items at once + +### Error Responses + +When you hit the limit: +```json +{ + "errors": [{ + "message": "Complexity budget exhausted", + "extensions": { "code": "complexityBudgetExhausted" } + }] +} +``` + +HTTP 429 response (minute-based rate limit): +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 30 +``` + +## Handling Rate Limits in Code + +### Using the SDK retry helper (recommended): + +```typescript +import { withRetry } from '@mondaydotcomorg/api'; + +// Automatically retries on 429 and complexity errors with exponential backoff +const result = await withRetry( + () => client.request(query, variables), + { maxRetries: 3 } +); +``` + +### Manual handling: + +```typescript +import { ClientError } from '@mondaydotcomorg/api'; + +try { + const result = await client.request(query, variables); +} catch (error) { + if (error instanceof ClientError) { + const gqlErrors = error.response?.errors; + const isComplexity = gqlErrors?.some( + e => e.extensions?.code === 'complexityBudgetExhausted' + ); + const isRateLimit = error.response?.status === 429; + + if (isComplexity || isRateLimit) { + // Wait and retry + await new Promise(r => setTimeout(r, 10000)); + // retry... + } + } +} +``` + +## Rate Limit Tiers + +| Limit Type | Budget | Resets | +|-----------|--------|--------| +| Complexity (per minute) | 5,000,000 – 10,000,000 (varies by plan) | Regenerates continuously | +| Requests per minute | 5,000 | Per 60 seconds | +| Concurrent requests | 80 | — | + +## Tips for High-Volume Operations + +1. **Add delays between writes** — 100-200ms between mutations prevents bursting +2. **Monitor `complexity.after`** — if below 10% of starting budget, pause +3. **Use `withRetry` from the SDK** — handles backoff automatically +4. **Prefer batch operations** — `change_multiple_column_values` over individual column changes +5. **Cache board structure** — don't re-query columns/groups repeatedly diff --git a/packages/api/README.md b/packages/api/README.md index 26e97fc..dfad156 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -246,6 +246,66 @@ note that after usage, you'l get all the available fields, with no regard to the **But there's a solution, look [here!](https://www.npmjs.com/package/@mondaydotcomorg/setup-api)** +## Agent & Automation Helpers + +The SDK includes utilities designed for AI agents and automation scripts that need to interact with monday.com reliably. + +### Column Value Builders + +Type-safe helpers that produce correctly formatted JSON for column value mutations: + +```typescript +import { ColumnValues } from '@mondaydotcomorg/api'; + +ColumnValues.status("Done") // '{"label":"Done"}' +ColumnValues.date("2024-06-15") // '{"date":"2024-06-15"}' +ColumnValues.people([123], [456]) // '{"personsAndTeams":[{"id":123,"kind":"person"},{"id":456,"kind":"team"}]}' +ColumnValues.dropdown(["A", "B"]) // '{"labels":["A","B"]}' +ColumnValues.link("https://x.com") // '{"url":"https://x.com","text":"https://x.com"}' +ColumnValues.clear() // 'null' +``` + +### Pagination Helper + +Async generator that auto-follows cursors: + +```typescript +import { paginateItems } from '@mondaydotcomorg/api'; + +for await (const page of paginateItems(client, "board_id")) { + for (const item of page) { + console.log(item.id, item.name); + } +} +``` + +### Retry with Backoff + +Automatic retry on rate limits (429) and complexity budget exhaustion: + +```typescript +import { withRetry } from '@mondaydotcomorg/api'; + +const result = await withRetry( + () => client.request(query, variables), + { maxRetries: 3, initialDelayMs: 1000 } +); +``` + +### Complexity Budget Monitoring + +```typescript +import { getComplexityBudget, getComplexityFromResponse } from '@mondaydotcomorg/api'; + +// Check current budget +const budget = await getComplexityBudget(client); +console.log(`Remaining: ${budget.after}, resets in ${budget.resetInSeconds}s`); + +// Extract from any raw response +const response = await client.rawRequest(query, variables); +const complexity = getComplexityFromResponse(response); +``` + ## LLM Context IF you're using an AI assistant/LLM to help you code, use the rules in the `llm-context` package to make it more likely for your LLM to write good code - [LLM Context Docs](https://github.com/mondaycom/monday-graphql-api/blob/main/packages/llm-context/README.md) \ No newline at end of file diff --git a/packages/api/lib/helpers/column-values.ts b/packages/api/lib/helpers/column-values.ts new file mode 100644 index 0000000..d44a9d1 --- /dev/null +++ b/packages/api/lib/helpers/column-values.ts @@ -0,0 +1,108 @@ +/** + * Column value builders for monday.com API. + * These helpers produce correctly-formatted JSON strings for use with + * change_column_value / change_multiple_column_values mutations. + */ + +export const ColumnValues = { + status(label: string): string { + return JSON.stringify({ label }); + }, + + statusByIndex(index: number): string { + return JSON.stringify({ index }); + }, + + text(value: string): string { + return JSON.stringify(value); + }, + + number(value: number): string { + return JSON.stringify(value); + }, + + date(date: string, time?: string): string { + const obj: { date: string; time?: string } = { date }; + if (time) obj.time = time; + return JSON.stringify(obj); + }, + + people(personIds: number[], teamIds?: number[]): string { + const personsAndTeams: Array<{ id: number; kind: 'person' | 'team' }> = []; + for (const id of personIds) { + personsAndTeams.push({ id, kind: 'person' }); + } + if (teamIds) { + for (const id of teamIds) { + personsAndTeams.push({ id, kind: 'team' }); + } + } + return JSON.stringify({ personsAndTeams }); + }, + + dropdown(labels: string[]): string { + return JSON.stringify({ labels }); + }, + + dropdownByIds(ids: number[]): string { + return JSON.stringify({ ids }); + }, + + timeline(from: string, to: string): string { + return JSON.stringify({ from, to }); + }, + + checkbox(checked: boolean): string { + return JSON.stringify({ checked: checked ? 'true' : 'false' }); + }, + + rating(value: number): string { + return JSON.stringify({ rating: value }); + }, + + email(email: string, text?: string): string { + return JSON.stringify({ email, text: text ?? email }); + }, + + phone(phone: string, countryShortName: string): string { + return JSON.stringify({ phone, countryShortName }); + }, + + link(url: string, text?: string): string { + return JSON.stringify({ url, text: text ?? url }); + }, + + longText(text: string): string { + return JSON.stringify({ text }); + }, + + hour(hour: number, minute?: number): string { + return JSON.stringify({ hour, minute: minute ?? 0 }); + }, + + location(lat: number, lng: number, address?: string): string { + const obj: { lat: number; lng: number; address?: string } = { lat, lng }; + if (address) obj.address = address; + return JSON.stringify(obj); + }, + + country(countryCode: string, countryName: string): string { + return JSON.stringify({ countryCode, countryName }); + }, + + tags(tagIds: number[]): string { + return JSON.stringify({ tag_ids: tagIds }); + }, + + week(startDate: string, endDate: string): string { + return JSON.stringify({ week: { startDate, endDate } }); + }, + + boardRelation(itemIds: number[]): string { + return JSON.stringify({ item_ids: itemIds }); + }, + + clear(): string { + return JSON.stringify(null); + }, +} as const; diff --git a/packages/api/lib/helpers/index.ts b/packages/api/lib/helpers/index.ts new file mode 100644 index 0000000..4b926b7 --- /dev/null +++ b/packages/api/lib/helpers/index.ts @@ -0,0 +1,7 @@ +export { ColumnValues } from './column-values'; +export { paginateItems, getAllItems } from './paginate'; +export type { PaginateOptions, ItemsPage } from './paginate'; +export { withRetry } from './retry'; +export type { RetryOptions } from './retry'; +export { getComplexityFromResponse, getComplexityBudget } from './rate-limit'; +export type { ComplexityInfo } from './rate-limit'; diff --git a/packages/api/lib/helpers/paginate.ts b/packages/api/lib/helpers/paginate.ts new file mode 100644 index 0000000..e9cc319 --- /dev/null +++ b/packages/api/lib/helpers/paginate.ts @@ -0,0 +1,97 @@ +import { ApiClient } from '../api-client/api-client'; + +export interface PaginateOptions { + limit?: number; + timeoutMs?: number; +} + +export interface ItemsPage { + cursor: string | null; + items: T[]; +} + +/** + * Async generator that auto-follows cursors from boards.items_page / next_items_page. + * Yields one page of items at a time. + * + * Usage: + * ```ts + * for await (const page of paginateItems(client, boardId)) { + * for (const item of page) { ... } + * } + * ``` + */ +export async function* paginateItems( + client: ApiClient, + boardId: string, + options: PaginateOptions = {}, +): AsyncGenerator { + const limit = options.limit ?? 100; + const reqOptions = options.timeoutMs ? { timeoutMs: options.timeoutMs } : undefined; + + const firstQuery = ` + query ($boardId: [ID!]!, $limit: Int!) { + boards(ids: $boardId) { + items_page(limit: $limit) { + cursor + items { + id + name + column_values { id type text value } + } + } + } + } + `; + + const firstResult = await client.request<{ + boards: Array<{ items_page: { cursor: string | null; items: any[] } }>; + }>(firstQuery, { boardId: [boardId], limit }, reqOptions); + + const firstPage = firstResult.boards?.[0]?.items_page; + if (!firstPage || firstPage.items.length === 0) return; + + yield firstPage.items; + let cursor = firstPage.cursor; + + const nextQuery = ` + query ($cursor: String!, $limit: Int!) { + next_items_page(cursor: $cursor, limit: $limit) { + cursor + items { + id + name + column_values { id type text value } + } + } + } + `; + + while (cursor) { + const result = await client.request<{ + next_items_page: { cursor: string | null; items: any[] }; + }>(nextQuery, { cursor, limit }, reqOptions); + + const page = result.next_items_page; + if (!page || page.items.length === 0) return; + + yield page.items; + cursor = page.cursor; + } +} + +/** + * Convenience function that collects all items across all pages into a single array. + * Use with caution on large boards — prefer the async generator for streaming. + */ +export async function getAllItems( + client: ApiClient, + boardId: string, + options: PaginateOptions = {}, +): Promise { + const allItems: any[] = []; + for await (const page of paginateItems(client, boardId, options)) { + allItems.push(...page); + } + return allItems; +} diff --git a/packages/api/lib/helpers/rate-limit.ts b/packages/api/lib/helpers/rate-limit.ts new file mode 100644 index 0000000..5d30eb0 --- /dev/null +++ b/packages/api/lib/helpers/rate-limit.ts @@ -0,0 +1,52 @@ +import { ApiClient } from '../api-client/api-client'; + +export interface ComplexityInfo { + before: number; + after: number; + query: number; + resetInSeconds: number; +} + +/** + * Extracts complexity information from a raw API response. + * Use with `client.rawRequest()` to monitor your complexity budget. + * + * Usage: + * ```ts + * const response = await client.rawRequest(query, variables); + * const complexity = getComplexityFromResponse(response); + * if (complexity && complexity.after < 100) { + * // approaching limit, slow down + * } + * ``` + */ +export function getComplexityFromResponse(response: { extensions?: Record }): ComplexityInfo | null { + const extensions = response?.extensions; + if (!extensions?.complexity) return null; + + const c = extensions.complexity; + return { + before: c.before, + after: c.after, + query: c.query, + resetInSeconds: c.reset_in_x_seconds, + }; +} + +/** + * Queries the current complexity budget without consuming much of it. + * Returns the complexity info from the API. + */ +export async function getComplexityBudget(client: ApiClient): Promise { + const response = await client.rawRequest<{ complexity: any }>( + `query { complexity { before after query reset_in_x_seconds } }`, + ); + const data = response.data?.complexity; + if (!data) return null; + return { + before: data.before, + after: data.after, + query: data.query, + resetInSeconds: data.reset_in_x_seconds, + }; +} diff --git a/packages/api/lib/helpers/retry.ts b/packages/api/lib/helpers/retry.ts new file mode 100644 index 0000000..8b47d84 --- /dev/null +++ b/packages/api/lib/helpers/retry.ts @@ -0,0 +1,89 @@ +import { ClientError } from 'graphql-request'; + +export interface RetryOptions { + maxRetries?: number; + initialDelayMs?: number; + maxDelayMs?: number; + retryOnComplexity?: boolean; + retryOnRateLimit?: boolean; +} + +const DEFAULT_RETRY_OPTIONS: Required = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 30000, + retryOnComplexity: true, + retryOnRateLimit: true, +}; + +function isRetryableError(error: unknown, options: Required): boolean { + if (error instanceof ClientError) { + const status = error.response?.status; + if (status === 429 && options.retryOnRateLimit) return true; + if (status === 503) return true; + + const errors = error.response?.errors; + if (errors && Array.isArray(errors)) { + for (const err of errors) { + const code = err.extensions?.code; + if (code === 'complexityBudgetExhausted' && options.retryOnComplexity) return true; + if (code === 'rateLimitExceeded' && options.retryOnRateLimit) return true; + } + } + } + return false; +} + +function getRetryAfterMs(error: unknown): number | null { + if (error instanceof ClientError) { + const headers = error.response?.headers; + if (headers) { + const retryAfter = headers instanceof Map ? headers.get('retry-after') : null; + if (retryAfter) { + const seconds = parseFloat(retryAfter); + if (!isNaN(seconds)) return seconds * 1000; + } + } + } + return null; +} + +function calculateDelay(attempt: number, options: Required, error: unknown): number { + const retryAfter = getRetryAfterMs(error); + if (retryAfter) return Math.min(retryAfter, options.maxDelayMs); + + const exponentialDelay = options.initialDelayMs * Math.pow(2, attempt); + const jitter = Math.random() * options.initialDelayMs * 0.5; + return Math.min(exponentialDelay + jitter, options.maxDelayMs); +} + +/** + * Wraps an async operation with retry logic using exponential backoff. + * Retries on rate limit (429), complexity budget exhausted, and 503 errors. + * + * Usage: + * ```ts + * const result = await withRetry(() => client.request(query, variables), { maxRetries: 3 }); + * ``` + */ +export async function withRetry( + operation: () => Promise, + options: RetryOptions = {}, +): Promise { + const opts = { ...DEFAULT_RETRY_OPTIONS, ...options }; + + let lastError: unknown; + for (let attempt = 0; attempt <= opts.maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + if (attempt === opts.maxRetries || !isRetryableError(error, opts)) { + throw error; + } + const delay = calculateDelay(attempt, opts, error); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw lastError; +} diff --git a/packages/api/lib/index.ts b/packages/api/lib/index.ts index 9d64dab..3e3fd6e 100644 --- a/packages/api/lib/index.ts +++ b/packages/api/lib/index.ts @@ -1,3 +1,4 @@ export * from './generated/sdk'; export * from './api-client'; export * from './constants/index'; +export * from './helpers'; diff --git a/packages/api/tests/helpers/column-values.test.ts b/packages/api/tests/helpers/column-values.test.ts new file mode 100644 index 0000000..4eed6e2 --- /dev/null +++ b/packages/api/tests/helpers/column-values.test.ts @@ -0,0 +1,176 @@ +import { ColumnValues } from '../../lib/helpers/column-values'; + +describe('ColumnValues', () => { + describe('status', () => { + it('should format status by label', () => { + expect(ColumnValues.status('Done')).toBe('{"label":"Done"}'); + }); + }); + + describe('statusByIndex', () => { + it('should format status by index', () => { + expect(ColumnValues.statusByIndex(1)).toBe('{"index":1}'); + }); + }); + + describe('text', () => { + it('should format text value', () => { + expect(ColumnValues.text('Hello')).toBe('"Hello"'); + }); + }); + + describe('number', () => { + it('should format number value', () => { + expect(ColumnValues.number(42)).toBe('42'); + }); + }); + + describe('date', () => { + it('should format date without time', () => { + expect(ColumnValues.date('2024-06-15')).toBe('{"date":"2024-06-15"}'); + }); + + it('should format date with time', () => { + expect(ColumnValues.date('2024-06-15', '14:30:00')).toBe('{"date":"2024-06-15","time":"14:30:00"}'); + }); + }); + + describe('people', () => { + it('should format people with person IDs', () => { + const result = JSON.parse(ColumnValues.people([123, 456])); + expect(result.personsAndTeams).toEqual([ + { id: 123, kind: 'person' }, + { id: 456, kind: 'person' }, + ]); + }); + + it('should format people with person and team IDs', () => { + const result = JSON.parse(ColumnValues.people([123], [789])); + expect(result.personsAndTeams).toEqual([ + { id: 123, kind: 'person' }, + { id: 789, kind: 'team' }, + ]); + }); + }); + + describe('dropdown', () => { + it('should format dropdown by labels', () => { + expect(ColumnValues.dropdown(['A', 'B'])).toBe('{"labels":["A","B"]}'); + }); + }); + + describe('dropdownByIds', () => { + it('should format dropdown by IDs', () => { + expect(ColumnValues.dropdownByIds([1, 2])).toBe('{"ids":[1,2]}'); + }); + }); + + describe('timeline', () => { + it('should format timeline with from/to', () => { + expect(ColumnValues.timeline('2024-01-01', '2024-01-31')).toBe('{"from":"2024-01-01","to":"2024-01-31"}'); + }); + }); + + describe('checkbox', () => { + it('should format checked checkbox', () => { + expect(ColumnValues.checkbox(true)).toBe('{"checked":"true"}'); + }); + + it('should format unchecked checkbox', () => { + expect(ColumnValues.checkbox(false)).toBe('{"checked":"false"}'); + }); + }); + + describe('email', () => { + it('should format email with default text', () => { + const result = JSON.parse(ColumnValues.email('test@example.com')); + expect(result).toEqual({ email: 'test@example.com', text: 'test@example.com' }); + }); + + it('should format email with custom text', () => { + const result = JSON.parse(ColumnValues.email('test@example.com', 'Contact')); + expect(result).toEqual({ email: 'test@example.com', text: 'Contact' }); + }); + }); + + describe('phone', () => { + it('should format phone value', () => { + expect(ColumnValues.phone('+1234567890', 'US')).toBe('{"phone":"+1234567890","countryShortName":"US"}'); + }); + }); + + describe('link', () => { + it('should format link with default text', () => { + const result = JSON.parse(ColumnValues.link('https://example.com')); + expect(result).toEqual({ url: 'https://example.com', text: 'https://example.com' }); + }); + + it('should format link with custom text', () => { + const result = JSON.parse(ColumnValues.link('https://example.com', 'Click')); + expect(result).toEqual({ url: 'https://example.com', text: 'Click' }); + }); + }); + + describe('longText', () => { + it('should format long text', () => { + expect(ColumnValues.longText('Multi\nline')).toBe('{"text":"Multi\\nline"}'); + }); + }); + + describe('hour', () => { + it('should format hour with default minute', () => { + expect(ColumnValues.hour(14)).toBe('{"hour":14,"minute":0}'); + }); + + it('should format hour with minute', () => { + expect(ColumnValues.hour(14, 30)).toBe('{"hour":14,"minute":30}'); + }); + }); + + describe('location', () => { + it('should format location without address', () => { + expect(ColumnValues.location(40.7, -74.0)).toBe('{"lat":40.7,"lng":-74}'); + }); + + it('should format location with address', () => { + const result = JSON.parse(ColumnValues.location(40.7, -74.0, 'NYC')); + expect(result).toEqual({ lat: 40.7, lng: -74, address: 'NYC' }); + }); + }); + + describe('country', () => { + it('should format country value', () => { + expect(ColumnValues.country('US', 'United States')).toBe('{"countryCode":"US","countryName":"United States"}'); + }); + }); + + describe('tags', () => { + it('should format tag IDs', () => { + expect(ColumnValues.tags([123, 456])).toBe('{"tag_ids":[123,456]}'); + }); + }); + + describe('week', () => { + it('should format week value', () => { + expect(ColumnValues.week('2024-06-10', '2024-06-16')).toBe('{"week":{"startDate":"2024-06-10","endDate":"2024-06-16"}}'); + }); + }); + + describe('boardRelation', () => { + it('should format board relation IDs', () => { + expect(ColumnValues.boardRelation([111, 222])).toBe('{"item_ids":[111,222]}'); + }); + }); + + describe('rating', () => { + it('should format rating value', () => { + expect(ColumnValues.rating(4)).toBe('{"rating":4}'); + }); + }); + + describe('clear', () => { + it('should return null JSON', () => { + expect(ColumnValues.clear()).toBe('null'); + }); + }); +}); diff --git a/packages/api/tests/helpers/rate-limit.test.ts b/packages/api/tests/helpers/rate-limit.test.ts new file mode 100644 index 0000000..f523855 --- /dev/null +++ b/packages/api/tests/helpers/rate-limit.test.ts @@ -0,0 +1,32 @@ +import { getComplexityFromResponse } from '../../lib/helpers/rate-limit'; + +describe('getComplexityFromResponse', () => { + it('should extract complexity info from response extensions', () => { + const response = { + extensions: { + complexity: { + before: 10000000, + after: 9999500, + query: 500, + reset_in_x_seconds: 30, + }, + }, + }; + + const result = getComplexityFromResponse(response); + expect(result).toEqual({ + before: 10000000, + after: 9999500, + query: 500, + resetInSeconds: 30, + }); + }); + + it('should return null when no extensions', () => { + expect(getComplexityFromResponse({})).toBeNull(); + }); + + it('should return null when no complexity in extensions', () => { + expect(getComplexityFromResponse({ extensions: {} })).toBeNull(); + }); +}); diff --git a/packages/api/tests/helpers/retry.test.ts b/packages/api/tests/helpers/retry.test.ts new file mode 100644 index 0000000..c59a84e --- /dev/null +++ b/packages/api/tests/helpers/retry.test.ts @@ -0,0 +1,75 @@ +import { withRetry } from '../../lib/helpers/retry'; +import { ClientError } from 'graphql-request'; + +function createClientError(status: number, extensions?: { code: string }): ClientError { + const response: any = { + status, + errors: extensions ? [{ message: 'error', extensions }] : [], + }; + return new ClientError(response, { query: '' }); +} + +describe('withRetry', () => { + it('should return result on first success', async () => { + const result = await withRetry(() => Promise.resolve('ok')); + expect(result).toBe('ok'); + }); + + it('should retry on 429 and succeed', async () => { + let attempts = 0; + const result = await withRetry( + () => { + attempts++; + if (attempts < 3) throw createClientError(429); + return Promise.resolve('success'); + }, + { maxRetries: 3, initialDelayMs: 10 }, + ); + expect(result).toBe('success'); + expect(attempts).toBe(3); + }); + + it('should retry on complexity budget exhausted', async () => { + let attempts = 0; + const result = await withRetry( + () => { + attempts++; + if (attempts < 2) throw createClientError(200, { code: 'complexityBudgetExhausted' }); + return Promise.resolve('done'); + }, + { maxRetries: 3, initialDelayMs: 10 }, + ); + expect(result).toBe('done'); + expect(attempts).toBe(2); + }); + + it('should throw non-retryable errors immediately', async () => { + const error = new Error('bad input'); + await expect( + withRetry(() => Promise.reject(error), { maxRetries: 3, initialDelayMs: 10 }), + ).rejects.toThrow('bad input'); + }); + + it('should throw after max retries exhausted', async () => { + await expect( + withRetry( + () => Promise.reject(createClientError(429)), + { maxRetries: 2, initialDelayMs: 10 }, + ), + ).rejects.toThrow(); + }); + + it('should not retry rate limit when retryOnRateLimit is false', async () => { + let attempts = 0; + await expect( + withRetry( + () => { + attempts++; + return Promise.reject(createClientError(429)); + }, + { maxRetries: 3, initialDelayMs: 10, retryOnRateLimit: false }, + ), + ).rejects.toThrow(); + expect(attempts).toBe(1); + }); +});