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
340 lines
10 KiB
TypeScript
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
|
|
}
|