/** * CMDBService - Universal schema-driven CMDB service * * Provides a unified interface for all CMDB operations: * - Reads from cache for fast access * - Write-through to Jira with conflict detection * - Schema-driven parsing and updates */ import { logger } from './logger.js'; import { cacheStore, type CacheStats } from './cacheStore.js'; import { jiraAssetsClient, type JiraUpdatePayload, JiraObjectNotFoundError } from './jiraAssetsClient.js'; import { conflictResolver, type ConflictCheckResult } from './conflictResolver.js'; import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js'; import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js'; // ============================================================================= // Types // ============================================================================= export interface GetObjectOptions { /** Force refresh from Jira (bypasses cache) */ forceRefresh?: boolean; } export interface UpdateResult { success: boolean; data?: CMDBObject; conflict?: ConflictCheckResult; error?: string; } export interface SearchOptions { limit?: number; offset?: number; searchTerm?: string; } // ============================================================================= // Service Implementation // ============================================================================= class CMDBService { // ========================================================================== // Read Operations // ========================================================================== /** * Get a single object by ID * By default reads from cache; use forceRefresh to fetch from Jira */ async getObject( typeName: CMDBObjectTypeName, id: string, options?: GetObjectOptions ): Promise { // Force refresh: always fetch from Jira if (options?.forceRefresh) { return this.fetchAndCacheObject(typeName, id); } // Try cache first const cached = await cacheStore.getObject(typeName, id); if (cached) { return cached; } // Cache miss: fetch from Jira return this.fetchAndCacheObject(typeName, id); } /** * Get a single object by object key (e.g., "ICMT-123") */ async getObjectByKey( typeName: CMDBObjectTypeName, objectKey: string, options?: GetObjectOptions ): Promise { // Force refresh: search Jira by key if (options?.forceRefresh) { const typeDef = OBJECT_TYPES[typeName]; if (!typeDef) return null; const iql = `objectType = "${typeDef.name}" AND Key = "${objectKey}"`; const result = await jiraAssetsClient.searchObjects(iql, 1, 1); if (result.objects.length === 0) return null; const parsed = jiraAssetsClient.parseObject(result.objects[0]); if (parsed) { await cacheStore.upsertObject(typeName, parsed); await cacheStore.extractAndStoreRelations(typeName, parsed); } return parsed; } // Try cache first const cached = await cacheStore.getObjectByKey(typeName, objectKey); if (cached) { return cached; } // Cache miss: search Jira return this.getObjectByKey(typeName, objectKey, { forceRefresh: true }); } /** * Fetch a single object from Jira and update cache * If the object was deleted from Jira (404), it will be removed from the local cache */ private async fetchAndCacheObject( typeName: CMDBObjectTypeName, id: string ): Promise { try { const jiraObj = await jiraAssetsClient.getObject(id); if (!jiraObj) return null; const parsed = jiraAssetsClient.parseObject(jiraObj); if (parsed) { await cacheStore.upsertObject(typeName, parsed); await cacheStore.extractAndStoreRelations(typeName, parsed); } return parsed; } catch (error) { // If object was deleted from Jira, remove it from our cache if (error instanceof JiraObjectNotFoundError) { const deleted = await cacheStore.deleteObject(typeName, id); if (deleted) { logger.info(`CMDBService: Removed deleted object ${typeName}/${id} from cache`); } return null; } // Re-throw other errors throw error; } } /** * Get all objects of a type from cache */ async getObjects( typeName: CMDBObjectTypeName, options?: SearchOptions ): Promise { if (options?.searchTerm) { return await cacheStore.searchByLabel(typeName, options.searchTerm, { limit: options.limit, offset: options.offset, }); } return await cacheStore.getObjects(typeName, { limit: options?.limit, offset: options?.offset, }); } /** * Count objects of a type in cache */ async countObjects(typeName: CMDBObjectTypeName): Promise { return await cacheStore.countObjects(typeName); } /** * Search across all object types */ async searchAllTypes(searchTerm: string, options?: { limit?: number }): Promise { return await cacheStore.searchAllTypes(searchTerm, { limit: options?.limit }); } /** * Get related objects (outbound references) */ async getRelatedObjects( sourceId: string, attributeName: string, targetTypeName: CMDBObjectTypeName ): Promise { return await cacheStore.getRelatedObjects(sourceId, targetTypeName, attributeName); } /** * Get objects that reference the given object (inbound references) */ async getReferencingObjects( targetId: string, sourceTypeName: CMDBObjectTypeName, attributeName?: string ): Promise { return await cacheStore.getReferencingObjects(targetId, sourceTypeName, attributeName); } // ========================================================================== // Write Operations // ========================================================================== /** * Update an object with conflict detection * * @param typeName - The object type * @param id - The object ID * @param updates - Field updates (only changed fields) * @param originalUpdatedAt - The _jiraUpdatedAt from when the object was loaded for editing */ async updateObject( typeName: CMDBObjectTypeName, id: string, updates: Record, originalUpdatedAt: string ): Promise { try { // 1. Check for conflicts const conflictResult = await conflictResolver.checkConflict( typeName, id, originalUpdatedAt, updates ); if (conflictResult.hasConflict && conflictResult.conflicts && conflictResult.conflicts.length > 0) { return { success: false, conflict: conflictResult, }; } // 2. Build Jira update payload const payload = this.buildUpdatePayload(typeName, updates); if (payload.attributes.length === 0) { logger.warn(`CMDBService: No attributes to update for ${typeName} ${id}`); return { success: true }; } // 3. Send update to Jira const success = await jiraAssetsClient.updateObject(id, payload); if (!success) { return { success: false, error: 'Failed to update object in Jira', }; } // 4. Fetch fresh data and update cache const freshData = await this.fetchAndCacheObject(typeName, id); logger.info(`CMDBService: Updated ${typeName} ${id}`); return { success: true, data: freshData || undefined, }; } catch (error) { logger.error(`CMDBService: Update failed for ${typeName} ${id}`, error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } } /** * Force update without conflict check (use with caution) */ async forceUpdateObject( typeName: CMDBObjectTypeName, id: string, updates: Record ): Promise { try { const payload = this.buildUpdatePayload(typeName, updates); if (payload.attributes.length === 0) { return { success: true }; } const success = await jiraAssetsClient.updateObject(id, payload); if (!success) { return { success: false, error: 'Failed to update object in Jira', }; } const freshData = await this.fetchAndCacheObject(typeName, id); return { success: true, data: freshData || undefined, }; } catch (error) { logger.error(`CMDBService: Force update failed for ${typeName} ${id}`, error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', }; } } /** * Build Jira update payload from field updates */ private buildUpdatePayload( typeName: CMDBObjectTypeName, updates: Record ): JiraUpdatePayload { const attributes: JiraUpdatePayload['attributes'] = []; logger.debug(`CMDBService.buildUpdatePayload: Building payload for ${typeName}`, { updateKeys: Object.keys(updates), updates: JSON.stringify(updates, null, 2) }); for (const [fieldName, value] of Object.entries(updates)) { const attrDef = getAttributeDefinition(typeName, fieldName); if (!attrDef) { logger.warn(`CMDBService: Unknown attribute ${fieldName} for ${typeName}`); continue; } if (!attrDef.isEditable) { logger.warn(`CMDBService: Attribute ${fieldName} is not editable`); continue; } const attrValues = this.buildAttributeValues(value, attrDef); logger.debug(`CMDBService.buildUpdatePayload: Attribute ${fieldName} (jiraId: ${attrDef.jiraId}, type: ${attrDef.type}, isMultiple: ${attrDef.isMultiple})`, { inputValue: JSON.stringify(value), outputValues: JSON.stringify(attrValues) }); attributes.push({ objectTypeAttributeId: attrDef.jiraId, objectAttributeValues: attrValues, }); } logger.debug(`CMDBService.buildUpdatePayload: Final payload`, { attributeCount: attributes.length, payload: JSON.stringify({ attributes }, null, 2) }); return { attributes }; } /** * Build attribute values for Jira API */ private buildAttributeValues( value: unknown, attrDef: { type: string; isMultiple: boolean } ): Array<{ value?: string }> { // Null/undefined = clear the field if (value === null || value === undefined) { return []; } // Reference type if (attrDef.type === 'reference') { if (attrDef.isMultiple && Array.isArray(value)) { return (value as ObjectReference[]).map(ref => ({ value: ref.objectKey, })); } else if (!attrDef.isMultiple) { const ref = value as ObjectReference; return [{ value: ref.objectKey }]; } return []; } // Boolean if (attrDef.type === 'boolean') { return [{ value: value ? 'true' : 'false' }]; } // Number types if (attrDef.type === 'integer' || attrDef.type === 'float') { return [{ value: String(value) }]; } // String types return [{ value: String(value) }]; } // ========================================================================== // Cache Management // ========================================================================== /** * Get cache statistics */ async getCacheStats(): Promise { return await cacheStore.getStats(); } /** * Check if cache has data */ async isCacheWarm(): Promise { return await cacheStore.isWarm(); } /** * Clear cache for a specific type */ async clearCacheForType(typeName: CMDBObjectTypeName): Promise { await cacheStore.clearObjectType(typeName); } /** * Clear entire cache */ async clearCache(): Promise { await cacheStore.clearAll(); } // ========================================================================== // User Token Management (for OAuth) // ========================================================================== /** * Set user token for current request */ setUserToken(token: string | null): void { if (token) { jiraAssetsClient.setRequestToken(token); } else { jiraAssetsClient.clearRequestToken(); } } /** * Clear user token */ clearUserToken(): void { jiraAssetsClient.clearRequestToken(); } } // Export singleton instance export const cmdbService = new CMDBService();