Skip to main content
Glama
status-transformer.ts11.9 kB
/** * Status transformer - converts status titles to Attio status object format * * Problem: LLMs commonly pass status values as strings (e.g., "Demo Scheduling") * but Attio API requires a structured object for status attributes. * * Solution: Auto-detect status attributes and transform string titles to the * required object format by looking up the status ID from the workspace options. * * Note: Attio expects the key `status` (not `status_id`). The value can be the * status UUID. We always wrap as an array to match Attio attribute value shapes. */ import { TransformContext, TransformResult, AttributeMetadata, AttributeOption, } from './types.js'; import { AttributeOptionsService } from '@/services/metadata/index.js'; import { isValidUUID } from '@/utils/validation/uuid-validation.js'; import { debug, error as logError, OperationType } from '@/utils/logger.js'; import { DEFAULT_ATTRIBUTES_CACHE_TTL } from '@/constants/universal.constants.js'; /** * Cache entry with timestamp for TTL expiration * @see Issue #984 - Add TTL to status cache */ interface StatusCacheEntry { data: AttributeOption[]; timestamp: number; } /** * Cache for status options with TTL-based expiration * @see Issue #984 - Add 5-minute TTL to prevent stale data */ const statusOptionsCache = new Map<string, StatusCacheEntry>(); /** * TTL for status options cache (5 minutes) */ const STATUS_CACHE_TTL = DEFAULT_ATTRIBUTES_CACHE_TTL; /** * Get cache key for status options */ function getCacheKey(objectSlug: string, attributeSlug: string): string { return `${objectSlug}:${attributeSlug}`; } /** * Clear the status options cache (useful for testing) */ export function clearStatusCache(): void { statusOptionsCache.clear(); } /** * Clean up expired cache entries (lazy eviction) * @see Issue #984 - Remove expired entries to prevent memory growth */ function cleanupExpiredEntries(): void { const now = Date.now(); const keysToDelete: string[] = []; for (const [key, entry] of statusOptionsCache.entries()) { if (now - entry.timestamp >= STATUS_CACHE_TTL) { keysToDelete.push(key); } } for (const key of keysToDelete) { statusOptionsCache.delete(key); } if (keysToDelete.length > 0) { debug( 'status-transformer', `Cleaned up ${keysToDelete.length} expired cache entries`, { expiredKeys: keysToDelete.length }, 'cleanupExpiredEntries', OperationType.DATA_PROCESSING ); } } /** * Fetch status options with TTL-based caching * @see Issue #984 - Add TTL expiration to prevent stale data */ async function getStatusOptionsWithCache( objectSlug: string, attributeSlug: string ): Promise<AttributeOption[]> { const cacheKey = getCacheKey(objectSlug, attributeSlug); const now = Date.now(); // Check cache with TTL if (statusOptionsCache.has(cacheKey)) { const cached = statusOptionsCache.get(cacheKey)!; const age = now - cached.timestamp; if (age < STATUS_CACHE_TTL) { debug( 'status-transformer', `Using cached status options for ${objectSlug}.${attributeSlug}`, { age: `${Math.round(age / 1000)}s`, ttl: `${STATUS_CACHE_TTL / 1000}s`, }, 'getStatusOptionsWithCache', OperationType.DATA_PROCESSING ); return cached.data; } // Expired, remove it statusOptionsCache.delete(cacheKey); debug( 'status-transformer', `Cache expired for ${objectSlug}.${attributeSlug}`, { age: `${Math.round(age / 1000)}s` }, 'getStatusOptionsWithCache', OperationType.DATA_PROCESSING ); } // Periodically clean up expired entries (every 10th fetch) if (Math.random() < 0.1) { cleanupExpiredEntries(); } // Fetch fresh data try { const result = await AttributeOptionsService.getOptions( objectSlug, attributeSlug, true // include archived for complete matching ); const options = result.options.map((opt) => { // Extract status ID - handle both string and object formats from Attio API let id = ''; if ('id' in opt && opt.id) { if (typeof opt.id === 'string') { // Simple string ID id = opt.id; } else if (typeof opt.id === 'object' && opt.id !== null) { // Object ID structure: { workspace_id, object_id, attribute_id, option_id } const idObj = opt.id as Record<string, unknown>; if ('option_id' in idObj && typeof idObj.option_id === 'string') { id = idObj.option_id; } else if ( 'status_id' in idObj && typeof idObj.status_id === 'string' ) { id = idObj.status_id; } } } return { id, title: opt.title, is_archived: opt.is_archived, }; }); // Cache with timestamp statusOptionsCache.set(cacheKey, { data: options, timestamp: now, }); debug( 'status-transformer', `Cached fresh status options for ${objectSlug}.${attributeSlug}`, { optionCount: options.length }, 'getStatusOptionsWithCache', OperationType.DATA_PROCESSING ); return options; } catch (err) { logError( 'status-transformer', `Failed to fetch status options for ${objectSlug}.${attributeSlug}`, err ); return []; } } /** * Find status ID by title (case-insensitive) */ function findStatusByTitle( options: AttributeOption[], title: string ): AttributeOption | undefined { const titleLower = title.toLowerCase().trim(); // First try exact match (case-insensitive) const exactMatch = options.find( (opt) => opt.title.toLowerCase() === titleLower ); if (exactMatch) return exactMatch; // Then try partial match const partialMatch = options.find( (opt) => opt.title.toLowerCase().includes(titleLower) || titleLower.includes(opt.title.toLowerCase()) ); return partialMatch; } /** * Check if a value is already in the correct status format */ function isStatusFormat(value: unknown): boolean { if (!Array.isArray(value) || value.length === 0) return false; const first = value[0]; if (!first || typeof first !== 'object' || Array.isArray(first)) return false; return 'status' in first; } function hasStringKey( value: unknown, key: 'status' | 'status_id' ): value is Record<string, unknown> & { [K in typeof key]: string } { return ( typeof value === 'object' && value !== null && !Array.isArray(value) && key in value && typeof (value as Record<string, unknown>)[key] === 'string' ); } function normalizeIncomingStatusValue(value: unknown): { normalized: unknown; extractedText?: string; } { // Already-correct Attio form: [{ status: "..." }] if (isStatusFormat(value)) { return { normalized: value }; } // Handle array-of-objects with the legacy key: [{ status_id: "..." }] → [{ status: "..." }] if ( Array.isArray(value) && value.length > 0 && hasStringKey(value[0], 'status_id') ) { return { normalized: [{ status: value[0].status_id }] }; } // Handle single object forms (common mistakes): { status: "..." } / { status_id: "..." } if (hasStringKey(value, 'status')) { return { normalized: [{ status: value.status }], extractedText: value.status, }; } if (hasStringKey(value, 'status_id')) { return { normalized: [{ status: value.status_id }], extractedText: value.status_id, }; } // Handle array of string values: ["Demo Scheduling"] → "Demo Scheduling" if ( Array.isArray(value) && value.length > 0 && typeof value[0] === 'string' ) { return { normalized: value, extractedText: value[0] }; } return { normalized: value }; } /** * Transform a status value from string title to Attio status object format * * @param value - The value to transform * @param attributeSlug - The attribute slug (e.g., "stage") * @param context - Transformation context * @param attributeMeta - Attribute metadata (must have type "status") * @returns Transform result */ export async function transformStatusValue( value: unknown, attributeSlug: string, context: TransformContext, attributeMeta: AttributeMetadata ): Promise<TransformResult> { // Only transform status type attributes if (attributeMeta.type !== 'status') { return { transformed: false, originalValue: value, transformedValue: value, }; } const normalizedIncoming = normalizeIncomingStatusValue(value); const normalizedValue = normalizedIncoming.normalized; // Skip if already in correct Attio format after normalization if (isStatusFormat(normalizedValue)) { return { transformed: normalizedValue !== value, originalValue: value, transformedValue: normalizedValue, description: normalizedValue !== value ? `Normalized status value format for ${attributeSlug}` : undefined, }; } const extractedText = typeof normalizedIncoming.extractedText === 'string' ? normalizedIncoming.extractedText : typeof normalizedValue === 'string' ? normalizedValue : undefined; // Only transform string-like values if (typeof extractedText !== 'string') { return { transformed: false, originalValue: value, transformedValue: normalizedValue, }; } // Short-circuit if value is already a UUID if (isValidUUID(extractedText)) { const transformedValue = [{ status: extractedText }]; debug( 'status-transformer', `Detected UUID string for status attribute`, { attribute: attributeSlug, from: extractedText, to: transformedValue, }, 'transformStatusValue', OperationType.DATA_PROCESSING ); return { transformed: true, originalValue: value, transformedValue, description: `Converted UUID string to status object for ${attributeSlug}`, }; } // Map resource type to object slug const objectSlug = context.resourceType.toLowerCase(); // Fetch status options const options = await getStatusOptionsWithCache(objectSlug, attributeSlug); if (options.length === 0) { debug( 'status-transformer', `No status options found for ${objectSlug}.${attributeSlug}`, { value: extractedText }, 'transformStatusValue', OperationType.DATA_PROCESSING ); return { transformed: false, originalValue: value, transformedValue: normalizedValue, }; } // Find matching status const match = findStatusByTitle(options, extractedText); if (!match) { // No match found - return error with valid options const validOptions = options .filter((opt) => !opt.is_archived) .map((opt) => `"${opt.title}"`) .join(', '); throw new Error( `Invalid status value "${extractedText}" for ${attributeSlug}. ` + `Valid options are: ${validOptions}` ); } // Transform to status ID format const transformedValue = [{ status: match.id }]; debug( 'status-transformer', `Transformed status value`, { attribute: attributeSlug, from: extractedText, to: transformedValue, matchedTitle: match.title, }, 'transformStatusValue', OperationType.DATA_PROCESSING ); return { transformed: true, originalValue: value, transformedValue, description: `Converted status title "${extractedText}" to status "${match.id}" (matched: "${match.title}")`, }; } /** * Get valid status options for error messages */ export async function getValidStatusOptions( objectSlug: string, attributeSlug: string ): Promise<string[]> { const options = await getStatusOptionsWithCache(objectSlug, attributeSlug); return options.filter((opt) => !opt.is_archived).map((opt) => opt.title); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kesslerio/attio-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server