Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

515 lines
15 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { mondayConnectorMeta } from '@/connectors/monday/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { parseMultiValue, parseTagDate } from '@/connectors/utils'
const logger = createLogger('MondayConnector')
/**
* monday.com GraphQL endpoint. All requests are POSTed here.
* @see https://developer.monday.com/api-reference/docs/basics
*/
const MONDAY_API_URL = 'https://api.monday.com/v2'
/**
* Stable monday.com API version pinned via the `API-Version` header. monday.com
* keeps at least three quarterly versions live; `2024-10` was deprecated on
* 2026-02-15, so this is pinned to the current stable release.
* @see https://developer.monday.com/api-reference/docs/api-versioning
*/
const MONDAY_API_VERSION = '2026-04'
/** Max items requested per `items_page` / `next_items_page` call (monday.com max is 500). */
const ITEMS_PAGE_SIZE = 100
/** Max boards requested per `boards` listing page (monday.com max is 500). */
const BOARDS_PAGE_SIZE = 100
/** Max updates fetched per item for content extraction. */
const UPDATES_LIMIT = 50
interface MondayColumnValue {
id: string
text: string | null
column: { id: string; title: string } | null
}
interface MondayUpdate {
id: string
text_body: string | null
created_at: string | null
creator: { name: string | null } | null
}
interface MondayItem {
id: string
name: string | null
state: string | null
created_at: string | null
updated_at: string | null
url: string | null
board: { id: string; name: string | null } | null
group: { id: string; title: string | null } | null
creator: { name: string | null } | null
column_values: MondayColumnValue[]
updates: MondayUpdate[]
}
interface MondayItemsPage {
cursor: string | null
items: MondayItem[]
}
interface MondayBoard {
id: string
name: string | null
items_page: MondayItemsPage | null
}
/**
* Pagination state encoded into `nextCursor`. Tracks which board in the
* configured/accessible list is being read (`boardIndex`) and the opaque
* `items_page` cursor within that board (`itemsCursor`).
*/
interface CursorState {
boardIndex: number
itemsCursor?: string
}
function encodeCursor(state: CursorState): string {
return Buffer.from(JSON.stringify(state), 'utf8').toString('base64url')
}
function decodeCursor(cursor?: string): CursorState {
if (!cursor) return { boardIndex: 0 }
try {
const json = Buffer.from(cursor, 'base64url').toString('utf8')
const parsed = JSON.parse(json) as Partial<CursorState>
return {
boardIndex: Number(parsed.boardIndex) || 0,
itemsCursor: typeof parsed.itemsCursor === 'string' ? parsed.itemsCursor : undefined,
}
} catch {
return { boardIndex: 0 }
}
}
/**
* monday.com uses the raw access token in the `Authorization` header — it is NOT
* prefixed with "Bearer". The `API-Version` header pins the schema version.
* @see https://developer.monday.com/api-reference/docs/authentication
*/
function mondayHeaders(accessToken: string): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: accessToken,
'API-Version': MONDAY_API_VERSION,
}
}
/**
* Executes a GraphQL query against the monday.com API, surfacing GraphQL-level
* errors (which return HTTP 200 with an `errors` array) as thrown errors.
*/
async function mondayGraphQL<T>(
accessToken: string,
query: string,
variables: Record<string, unknown> = {},
retryOptions?: Parameters<typeof fetchWithRetry>[2]
): Promise<T> {
const response = await fetchWithRetry(
MONDAY_API_URL,
{
method: 'POST',
headers: mondayHeaders(accessToken),
body: JSON.stringify({ query, variables }),
},
retryOptions
)
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(
`monday.com API HTTP error: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`
)
}
const data = (await response.json()) as {
data?: T
errors?: { message?: string }[]
error_message?: string
}
if (data.errors && data.errors.length > 0) {
const message = data.errors
.map((e) => e.message)
.filter(Boolean)
.join('; ')
throw new Error(`monday.com API error: ${message || 'Unknown GraphQL error'}`)
}
if (data.error_message) {
throw new Error(`monday.com API error: ${data.error_message}`)
}
return data.data as T
}
/**
* GraphQL selection set for an item, shared between listing and single-item
* fetches so the resolved fields stay in sync.
*/
const ITEM_FIELDS = `
id
name
state
created_at
updated_at
url
board { id name }
group { id title }
creator { name }
column_values {
id
text
column { id title }
}
updates(limit: ${UPDATES_LIMIT}) {
id
text_body
created_at
creator { name }
}
`
/**
* Builds the change-detection hash from item identity + last-modified time. Must
* be identical whether produced inline during listing or via a `getDocument`
* fetch, since monday's `updated_at` advances on any item or column change.
*/
function buildContentHash(itemId: string, updatedAt: string | null | undefined): string {
return `monday:${itemId}:${updatedAt ?? ''}`
}
/**
* Resolves a stable board name + id for an item, preferring the nested `board`
* field and falling back to the board the item was listed under.
*/
function resolveBoard(
item: MondayItem,
fallback?: { id: string; name: string | null }
): { id: string; name: string } {
const id = item.board?.id ?? fallback?.id ?? ''
const name = item.board?.name ?? fallback?.name ?? ''
return { id, name }
}
function buildSourceUrl(item: MondayItem): string | undefined {
return item.url ?? undefined
}
/**
* Builds the metadata object carried on every document, used both for tag mapping
* (`mapTags`) and for downstream display. Kept in one place so listing and
* single-item fetches emit identical metadata.
*/
function itemMetadata(
item: MondayItem,
board: { id: string; name: string }
): Record<string, unknown> {
return {
boardId: board.id,
boardName: board.name,
itemName: item.name ?? '',
groupTitle: item.group?.title ?? '',
state: item.state ?? '',
creatorName: item.creator?.name ?? '',
createdAt: item.created_at ?? undefined,
updatedAt: item.updated_at ?? undefined,
}
}
/**
* Produces a fully-hydrated document from a single `items_page` element. The list
* query already selects the complete item payload (`column_values` + `updates`),
* so content is built inline here — avoiding a redundant per-item `getDocument`
* round-trip. `contentHash` derives solely from id + `updated_at`, so it is
* identical whether produced here or via `getDocument`.
*/
function itemToDocument(
item: MondayItem,
fallbackBoard?: { id: string; name: string | null }
): ExternalDocument {
const board = resolveBoard(item, fallbackBoard)
return {
externalId: item.id,
title: item.name?.trim() || 'Untitled Item',
content: formatItemContent(item, board),
contentDeferred: false,
mimeType: 'text/plain',
sourceUrl: buildSourceUrl(item),
contentHash: buildContentHash(item.id, item.updated_at),
metadata: itemMetadata(item, board),
}
}
/**
* Formats an item's column values and updates into a plain-text document. The
* resolved board is passed in so listing (which has a board fallback) and
* single-item fetches produce identical content.
*/
function formatItemContent(item: MondayItem, board: { id: string; name: string }): string {
const parts: string[] = []
if (board.name) parts.push(`Board: ${board.name}`)
parts.push(`Item: ${item.name?.trim() || 'Untitled Item'}`)
if (item.group?.title) parts.push(`Group: ${item.group.title}`)
if (item.creator?.name) parts.push(`Created by: ${item.creator.name}`)
if (item.created_at) parts.push(`Created: ${item.created_at}`)
if (item.updated_at) parts.push(`Updated: ${item.updated_at}`)
const columns = item.column_values.filter((cv) => cv.text?.trim())
if (columns.length > 0) {
parts.push('')
parts.push('--- Fields ---')
for (const cv of columns) {
const title = cv.column?.title?.trim() || cv.id
parts.push(`${title}: ${cv.text}`)
}
}
const updates = item.updates.filter((u) => u.text_body?.trim())
if (updates.length > 0) {
parts.push('')
parts.push('--- Updates ---')
for (const update of updates) {
const author = update.creator?.name?.trim() || 'Unknown'
parts.push(`Update by ${author}: ${update.text_body}`)
}
}
return parts.join('\n')
}
/**
* Fetches the list of board ids the connector should sync. When `boardIds` is
* configured, those are used verbatim; otherwise all accessible active boards
* are enumerated.
*/
async function resolveBoardIds(
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ id: string; name: string | null }[]> {
const configured = parseMultiValue(sourceConfig.boardIds)
if (configured.length > 0) {
return configured.map((id) => ({ id, name: null }))
}
const boards: { id: string; name: string | null }[] = []
let page = 1
for (;;) {
const data = await mondayGraphQL<{ boards: { id: string; name: string | null }[] | null }>(
accessToken,
`query ($limit: Int!, $page: Int!) {
boards(limit: $limit, page: $page, state: active) {
id
name
}
}`,
{ limit: BOARDS_PAGE_SIZE, page }
)
const batch = data.boards ?? []
boards.push(...batch)
if (batch.length < BOARDS_PAGE_SIZE) break
page += 1
}
return boards
}
export const mondayConnector: ConnectorConfig = {
...mondayConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : 0
const state = decodeCursor(cursor)
const boards =
(syncContext?.boards as { id: string; name: string | null }[] | undefined) ??
(await resolveBoardIds(accessToken, sourceConfig))
if (syncContext) syncContext.boards = boards
if (state.boardIndex >= boards.length) {
return { documents: [], hasMore: false }
}
const board = boards[state.boardIndex]
const fallbackBoard = { id: board.id, name: board.name }
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
const pageLimit =
maxItems > 0
? Math.min(ITEMS_PAGE_SIZE, Math.max(1, maxItems - prevFetched))
: ITEMS_PAGE_SIZE
let itemsPage: MondayItemsPage | null
if (state.itemsCursor) {
const data = await mondayGraphQL<{ next_items_page: MondayItemsPage | null }>(
accessToken,
`query ($cursor: String!, $limit: Int!) {
next_items_page(cursor: $cursor, limit: $limit) {
cursor
items { ${ITEM_FIELDS} }
}
}`,
{ cursor: state.itemsCursor, limit: pageLimit }
)
itemsPage = data.next_items_page
} else {
const data = await mondayGraphQL<{ boards: MondayBoard[] | null }>(
accessToken,
`query ($ids: [ID!], $limit: Int!) {
boards(ids: $ids) {
id
name
items_page(limit: $limit) {
cursor
items { ${ITEM_FIELDS} }
}
}
}`,
{ ids: [board.id], limit: pageLimit }
)
itemsPage = data.boards?.[0]?.items_page ?? null
}
const items = itemsPage?.items ?? []
const nextItemsCursor = itemsPage?.cursor?.trim() || undefined
logger.info('Listing Monday.com items', {
boardIndex: state.boardIndex,
boardTotal: boards.length,
boardId: board.id,
itemCount: items.length,
hasItemsCursor: Boolean(state.itemsCursor),
})
const allDocuments = items.map((item) => itemToDocument(item, fallbackBoard))
let documents = allDocuments
if (maxItems > 0) {
const remaining = Math.max(0, maxItems - prevFetched)
if (allDocuments.length > remaining) {
documents = allDocuments.slice(0, remaining)
}
}
const totalFetched = prevFetched + documents.length
if (syncContext) syncContext.totalDocsFetched = totalFetched
const hitLimit = maxItems > 0 && totalFetched >= maxItems
if (hitLimit && syncContext) syncContext.listingCapped = true
let nextCursor: string | undefined
let hasMore = false
if (hitLimit) {
nextCursor = undefined
} else if (nextItemsCursor) {
nextCursor = encodeCursor({ boardIndex: state.boardIndex, itemsCursor: nextItemsCursor })
hasMore = true
} else if (state.boardIndex + 1 < boards.length) {
nextCursor = encodeCursor({ boardIndex: state.boardIndex + 1 })
hasMore = true
}
return { documents, nextCursor, hasMore }
},
getDocument: async (
accessToken: string,
_sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
try {
if (!externalId) return null
const data = await mondayGraphQL<{ items: MondayItem[] | null }>(
accessToken,
`query ($ids: [ID!]) {
items(ids: $ids) { ${ITEM_FIELDS} }
}`,
{ ids: [externalId] }
)
const item = data.items?.[0]
if (!item) return null
const doc = itemToDocument(item)
if (!doc.content.trim()) return null
return doc
} catch (error) {
logger.warn('Failed to get Monday.com item', {
externalId,
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const maxItems = sourceConfig.maxItems as string | undefined
if (maxItems && (Number.isNaN(Number(maxItems)) || Number(maxItems) < 0)) {
return { valid: false, error: 'Max items must be a non-negative number' }
}
try {
await mondayGraphQL(accessToken, `query { me { id } }`, {}, VALIDATE_RETRY_OPTIONS)
return { valid: true }
} catch (error) {
const message = getErrorMessage(error, 'Failed to validate configuration')
return { valid: false, error: message }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.boardName === 'string' && metadata.boardName.trim()) {
result.boardName = metadata.boardName
}
if (typeof metadata.groupTitle === 'string' && metadata.groupTitle.trim()) {
result.groupTitle = metadata.groupTitle
}
if (typeof metadata.itemName === 'string' && metadata.itemName.trim()) {
result.itemName = metadata.itemName
}
if (typeof metadata.state === 'string' && metadata.state.trim()) {
result.state = metadata.state
}
if (typeof metadata.creatorName === 'string' && metadata.creatorName.trim()) {
result.creatorName = metadata.creatorName
}
const createdAt = parseTagDate(metadata.createdAt)
if (createdAt) result.createdAt = createdAt
const updatedAt = parseTagDate(metadata.updatedAt)
if (updatedAt) result.updatedAt = updatedAt
return result
},
}