Files
simstudioai--sim/apps/sim/connectors/airtable/airtable.ts
T
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

340 lines
10 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 { airtableConnectorMeta } from '@/connectors/airtable/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { computeContentHash, parseTagDate } from '@/connectors/utils'
const logger = createLogger('AirtableConnector')
const AIRTABLE_API = 'https://api.airtable.com/v0'
const PAGE_SIZE = 100
/**
* Flattens a record's fields into a plain-text representation.
* Each field is rendered as "Field Name: value" on its own line.
*/
function recordToPlainText(
fields: Record<string, unknown>,
fieldNames?: Map<string, string>
): string {
const lines: string[] = []
for (const [key, value] of Object.entries(fields)) {
if (value == null) continue
const displayName = fieldNames?.get(key) ?? key
if (Array.isArray(value)) {
// Attachments or linked records
const items = value.map((v) => {
if (typeof v === 'object' && v !== null) {
const obj = v as Record<string, unknown>
return (obj.url as string) || (obj.name as string) || JSON.stringify(v)
}
return String(v)
})
lines.push(`${displayName}: ${items.join(', ')}`)
} else if (typeof value === 'object') {
lines.push(`${displayName}: ${JSON.stringify(value)}`)
} else {
lines.push(`${displayName}: ${String(value)}`)
}
}
return lines.join('\n')
}
/**
* Extracts a human-readable title from a record's fields.
* Prefers the configured title field, then falls back to common field names.
*/
function extractTitle(fields: Record<string, unknown>, titleField?: string): string {
if (titleField && fields[titleField] != null) {
return String(fields[titleField])
}
const candidates = ['Name', 'Title', 'name', 'title', 'Summary', 'summary']
for (const candidate of candidates) {
if (fields[candidate] != null) {
return String(fields[candidate])
}
}
for (const value of Object.values(fields)) {
if (typeof value === 'string' && value.trim()) {
return value.length > 80 ? `${value.slice(0, 80)}…` : value
}
}
return 'Untitled'
}
/**
* Parses the cursor format: "offset:<airtable_offset>"
*/
function parseCursor(cursor?: string): string | undefined {
if (!cursor) return undefined
if (cursor.startsWith('offset:')) return cursor.slice(7)
return cursor
}
export const airtableConnector: ConnectorConfig = {
...airtableConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const baseId = sourceConfig.baseId as string
const tableIdOrName = sourceConfig.tableIdOrName as string
const viewId = sourceConfig.viewId as string | undefined
const titleField = sourceConfig.titleField as string | undefined
const maxRecords = sourceConfig.maxRecords ? Number(sourceConfig.maxRecords) : 0
const fieldNames = await fetchFieldNames(accessToken, baseId, tableIdOrName, syncContext)
const params = new URLSearchParams()
params.append('pageSize', String(PAGE_SIZE))
if (viewId) params.append('view', viewId)
if (maxRecords > 0) params.append('maxRecords', String(maxRecords))
const offset = parseCursor(cursor)
if (offset) params.append('offset', offset)
const encodedTable = encodeURIComponent(tableIdOrName)
const url = `${AIRTABLE_API}/${baseId}/${encodedTable}?${params.toString()}`
logger.info(`Listing records from ${baseId}/${tableIdOrName}`, {
offset: offset ?? 'none',
view: viewId ?? 'default',
})
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to list Airtable records', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to list Airtable records: ${response.status}`)
}
const data = (await response.json()) as {
records: AirtableRecord[]
offset?: string
}
const records = data.records || []
const documents: ExternalDocument[] = await Promise.all(
records.map((record) =>
recordToDocument(record, baseId, tableIdOrName, titleField, fieldNames)
)
)
const nextOffset = data.offset
return {
documents,
nextCursor: nextOffset ? `offset:${nextOffset}` : undefined,
hasMore: Boolean(nextOffset),
}
},
getDocument: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const baseId = sourceConfig.baseId as string
const tableIdOrName = sourceConfig.tableIdOrName as string
const titleField = sourceConfig.titleField as string | undefined
const fieldNames = await fetchFieldNames(accessToken, baseId, tableIdOrName)
const encodedTable = encodeURIComponent(tableIdOrName)
const url = `${AIRTABLE_API}/${baseId}/${encodedTable}/${externalId}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
if (response.status === 404 || response.status === 422) return null
throw new Error(`Failed to get Airtable record: ${response.status}`)
}
const record = (await response.json()) as AirtableRecord
return recordToDocument(record, baseId, tableIdOrName, titleField, fieldNames)
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const baseId = sourceConfig.baseId as string
const tableIdOrName = sourceConfig.tableIdOrName as string
if (!baseId || !tableIdOrName) {
return { valid: false, error: 'Base ID and table name are required' }
}
if (baseId && !baseId.startsWith('app')) {
return { valid: false, error: 'Base ID should start with "app"' }
}
const maxRecords = sourceConfig.maxRecords as string | undefined
if (maxRecords && (Number.isNaN(Number(maxRecords)) || Number(maxRecords) <= 0)) {
return { valid: false, error: 'Max records must be a positive number' }
}
try {
const encodedTable = encodeURIComponent(tableIdOrName)
const url = `${AIRTABLE_API}/${baseId}/${encodedTable}?pageSize=1`
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
const errorText = await response.text()
if (response.status === 404 || response.status === 422) {
return { valid: false, error: `Table "${tableIdOrName}" not found in base "${baseId}"` }
}
if (response.status === 403) {
return { valid: false, error: 'Access denied. Check your Airtable permissions.' }
}
return { valid: false, error: `Airtable API error: ${response.status} - ${errorText}` }
}
const viewId = sourceConfig.viewId as string | undefined
if (viewId) {
const viewUrl = `${AIRTABLE_API}/${baseId}/${encodedTable}?pageSize=1&view=${encodeURIComponent(viewId)}`
const viewResponse = await fetchWithRetry(
viewUrl,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
VALIDATE_RETRY_OPTIONS
)
if (!viewResponse.ok) {
return { valid: false, error: `View "${viewId}" not found in table "${tableIdOrName}"` }
}
}
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> = {}
const createdTime = parseTagDate(metadata.createdTime)
if (createdTime) result.createdTime = createdTime
return result
},
}
interface AirtableRecord {
id: string
fields: Record<string, unknown>
createdTime: string
}
/**
* Converts an Airtable record to an ExternalDocument.
*/
async function recordToDocument(
record: AirtableRecord,
baseId: string,
tableIdOrName: string,
titleField: string | undefined,
fieldNames: Map<string, string>
): Promise<ExternalDocument> {
const plainText = recordToPlainText(record.fields, fieldNames)
const contentHash = await computeContentHash(plainText)
const title = extractTitle(record.fields, titleField)
const encodedTable = encodeURIComponent(tableIdOrName)
const sourceUrl = `https://airtable.com/${baseId}/${encodedTable}/${record.id}`
return {
externalId: record.id,
title,
content: plainText,
mimeType: 'text/plain',
sourceUrl,
contentHash,
metadata: {
createdTime: record.createdTime,
},
}
}
/**
* Fetches the table schema to build a field ID → field name mapping.
*/
async function fetchFieldNames(
accessToken: string,
baseId: string,
tableIdOrName: string,
syncContext?: Record<string, unknown>
): Promise<Map<string, string>> {
const cacheKey = `fieldNames:${baseId}/${tableIdOrName}`
if (syncContext?.[cacheKey]) return syncContext[cacheKey] as Map<string, string>
const fieldNames = new Map<string, string>()
try {
const url = `${AIRTABLE_API}/meta/bases/${baseId}/tables`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
logger.warn('Failed to fetch Airtable schema, using raw field keys', {
status: response.status,
})
return fieldNames
}
const data = (await response.json()) as {
tables: { id: string; name: string; fields: { id: string; name: string; type: string }[] }[]
}
const table = data.tables.find((t) => t.id === tableIdOrName || t.name === tableIdOrName)
if (table) {
for (const field of table.fields) {
fieldNames.set(field.id, field.name)
fieldNames.set(field.name, field.name)
}
}
} catch (error) {
logger.warn('Error fetching Airtable schema', {
error: toError(error).message,
})
}
if (syncContext) syncContext[cacheKey] = fieldNames
return fieldNames
}