chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { airtableConnector } from '@/connectors/airtable/airtable'
|
||||
@@ -0,0 +1,81 @@
|
||||
import { AirtableIcon } from '@/components/icons'
|
||||
import type { ConnectorMeta } from '@/connectors/types'
|
||||
|
||||
export const airtableConnectorMeta: ConnectorMeta = {
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
description: 'Sync records from an Airtable table',
|
||||
version: '1.0.0',
|
||||
icon: AirtableIcon,
|
||||
|
||||
auth: {
|
||||
mode: 'oauth',
|
||||
provider: 'airtable',
|
||||
requiredScopes: ['data.records:read', 'schema.bases:read'],
|
||||
},
|
||||
|
||||
configFields: [
|
||||
{
|
||||
id: 'baseSelector',
|
||||
title: 'Base',
|
||||
type: 'selector',
|
||||
selectorKey: 'airtable.bases',
|
||||
canonicalParamId: 'baseId',
|
||||
mode: 'basic',
|
||||
placeholder: 'Select a base',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'baseId',
|
||||
title: 'Base ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'baseId',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. appXXXXXXXXXXXXXX',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'tableSelector',
|
||||
title: 'Table',
|
||||
type: 'selector',
|
||||
selectorKey: 'airtable.tables',
|
||||
canonicalParamId: 'tableIdOrName',
|
||||
mode: 'basic',
|
||||
dependsOn: ['baseSelector'],
|
||||
placeholder: 'Select a table',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'tableIdOrName',
|
||||
title: 'Table Name or ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'tableIdOrName',
|
||||
mode: 'advanced',
|
||||
placeholder: 'e.g. Tasks or tblXXXXXXXXXXXXXX',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'viewId',
|
||||
title: 'View',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. Grid view or viwXXXXXXXXXXXXXX',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'titleField',
|
||||
title: 'Title Field',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. Name',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'maxRecords',
|
||||
title: 'Max Records',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. 1000 (default: unlimited)',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
|
||||
tagDefinitions: [{ id: 'createdTime', displayName: 'Created Time', fieldType: 'date' }],
|
||||
}
|
||||
Reference in New Issue
Block a user