/** * Schema Cache Service * * In-memory cache for schema data with TTL support. * Provides fast access to schema information without hitting the database on every request. */ import { logger } from './logger.js'; import { schemaDiscoveryService } from './schemaDiscoveryService.js'; import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jira-schema.js'; import { getDatabaseAdapter } from './database/singleton.js'; interface SchemaResponse { metadata: { generatedAt: string; objectTypeCount: number; totalAttributes: number; enabledObjectTypeCount?: number; }; objectTypes: Record; cacheCounts?: Record; jiraCounts?: Record; } interface ObjectTypeWithLinks extends ObjectTypeDefinition { enabled: boolean; // Whether this object type is enabled for syncing incomingLinks: Array<{ fromType: string; fromTypeName: string; attributeName: string; isMultiple: boolean; }>; outgoingLinks: Array<{ toType: string; toTypeName: string; attributeName: string; isMultiple: boolean; }>; } class SchemaCacheService { private cache: SchemaResponse | null = null; private cacheTimestamp: number = 0; private readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes private db = getDatabaseAdapter(); // Use shared database adapter singleton /** * Get schema from cache or fetch from database */ async getSchema(): Promise { // Check cache validity const now = Date.now(); if (this.cache && (now - this.cacheTimestamp) < this.CACHE_TTL_MS) { logger.debug('SchemaCache: Returning cached schema'); return this.cache; } // Cache expired or doesn't exist - fetch from database logger.debug('SchemaCache: Cache expired or missing, fetching from database'); const schema = await this.fetchFromDatabase(); // Update cache this.cache = schema; this.cacheTimestamp = now; return schema; } /** * Invalidate cache (force refresh on next request) */ invalidate(): void { logger.debug('SchemaCache: Invalidating cache'); this.cache = null; this.cacheTimestamp = 0; } /** * Fetch schema from database and build response * Returns ALL object types (enabled and disabled) with their sync status */ private async fetchFromDatabase(): Promise { // Schema discovery must be manually triggered via API endpoints // No automatic discovery on first run // Fetch ALL object types (enabled and disabled) with their schema info const objectTypeRows = await this.db.query<{ id: number; schema_id: number; jira_type_id: number; type_name: string; display_name: string; description: string | null; sync_priority: number; object_count: number; enabled: boolean | number; }>( `SELECT ot.id, ot.schema_id, ot.jira_type_id, ot.type_name, ot.display_name, ot.description, ot.sync_priority, ot.object_count, ot.enabled FROM object_types ot ORDER BY ot.sync_priority, ot.type_name` ); if (objectTypeRows.length === 0) { // No types found, return empty schema return { metadata: { generatedAt: new Date().toISOString(), objectTypeCount: 0, totalAttributes: 0, }, objectTypes: {}, }; } // Fetch attributes for ALL object types using JOIN const attributeRows = await this.db.query<{ id: number; jira_attr_id: number; object_type_name: string; attr_name: string; field_name: string; attr_type: string; is_multiple: boolean | number; is_editable: boolean | number; is_required: boolean | number; is_system: boolean | number; reference_type_name: string | null; description: string | null; position: number | null; schema_id: number; type_name: string; }>( `SELECT a.*, ot.schema_id, ot.type_name FROM attributes a INNER JOIN object_types ot ON a.object_type_name = ot.type_name ORDER BY ot.type_name, COALESCE(a.position, 0), a.jira_attr_id` ); logger.debug(`SchemaCache: Found ${objectTypeRows.length} object types (enabled and disabled) and ${attributeRows.length} attributes`); // Build object types with attributes // Use type_name as key (even if same type exists in multiple schemas, we'll show the first enabled one) // In practice, if same type_name exists in multiple schemas, attributes should be the same const objectTypesWithLinks: Record = {}; for (const typeRow of objectTypeRows) { const typeName = typeRow.type_name; // Skip if we already have this type_name (first enabled one wins) if (objectTypesWithLinks[typeName]) { logger.debug(`SchemaCache: Skipping duplicate type_name ${typeName} from schema ${typeRow.schema_id}`); continue; } // Match attributes by both schema_id and type_name to ensure correct mapping const matchingAttributes = attributeRows.filter(a => a.schema_id === typeRow.schema_id && a.type_name === typeName); logger.debug(`SchemaCache: Found ${matchingAttributes.length} attributes for ${typeName} (schema_id: ${typeRow.schema_id})`); const attributes = matchingAttributes.map(attrRow => { // Convert boolean/number for SQLite compatibility const isMultiple = typeof attrRow.is_multiple === 'boolean' ? attrRow.is_multiple : attrRow.is_multiple === 1; const isEditable = typeof attrRow.is_editable === 'boolean' ? attrRow.is_editable : attrRow.is_editable === 1; const isRequired = typeof attrRow.is_required === 'boolean' ? attrRow.is_required : attrRow.is_required === 1; const isSystem = typeof attrRow.is_system === 'boolean' ? attrRow.is_system : attrRow.is_system === 1; return { jiraId: attrRow.jira_attr_id, name: attrRow.attr_name, fieldName: attrRow.field_name, type: attrRow.attr_type as AttributeDefinition['type'], isMultiple, isEditable, isRequired, isSystem, referenceTypeName: attrRow.reference_type_name || undefined, description: attrRow.description || undefined, position: attrRow.position ?? 0, } as AttributeDefinition; }); // Convert enabled boolean/number to boolean const isEnabled = typeof typeRow.enabled === 'boolean' ? typeRow.enabled : typeRow.enabled === 1; objectTypesWithLinks[typeName] = { jiraTypeId: typeRow.jira_type_id, name: typeRow.display_name, typeName: typeName, syncPriority: typeRow.sync_priority, objectCount: typeRow.object_count, enabled: isEnabled, attributes, incomingLinks: [], outgoingLinks: [], }; } // Build link relationships for (const [typeName, typeDef] of Object.entries(objectTypesWithLinks)) { for (const attr of typeDef.attributes) { if (attr.type === 'reference' && attr.referenceTypeName) { // Add outgoing link from this type typeDef.outgoingLinks.push({ toType: attr.referenceTypeName, toTypeName: objectTypesWithLinks[attr.referenceTypeName]?.name || attr.referenceTypeName, attributeName: attr.name, isMultiple: attr.isMultiple, }); // Add incoming link to the referenced type if (objectTypesWithLinks[attr.referenceTypeName]) { objectTypesWithLinks[attr.referenceTypeName].incomingLinks.push({ fromType: typeName, fromTypeName: typeDef.name, attributeName: attr.name, isMultiple: attr.isMultiple, }); } } } } // Get cache counts (objectsByType) if available let cacheCounts: Record | undefined; try { const { dataService } = await import('./dataService.js'); const cacheStatus = await dataService.getCacheStatus(); cacheCounts = cacheStatus.objectsByType; } catch (err) { logger.debug('SchemaCache: Could not fetch cache counts', err); // Continue without cache counts - not critical } // Calculate metadata (include enabled count) const totalAttributes = Object.values(objectTypesWithLinks).reduce( (sum, t) => sum + t.attributes.length, 0 ); const enabledCount = Object.values(objectTypesWithLinks).filter(t => t.enabled).length; const response: SchemaResponse = { metadata: { generatedAt: new Date().toISOString(), objectTypeCount: objectTypeRows.length, totalAttributes, enabledObjectTypeCount: enabledCount, }, objectTypes: objectTypesWithLinks, cacheCounts, }; return response; } } // Export singleton instance export const schemaCacheService = new SchemaCacheService();