- Add PostgreSQL and SQLite database adapters with factory pattern - Add migration script for SQLite to PostgreSQL - Add production Dockerfiles and docker-compose configs - Add deployment documentation and scripts - Add BIA sync dashboard and matching service - Add data completeness configuration and components - Add new dashboard components (BusinessImportanceComparison, ComplexityDynamics, etc.) - Update various services and routes - Remove deprecated management-parameters.json and taxonomy files
450 lines
13 KiB
TypeScript
450 lines
13 KiB
TypeScript
/**
|
|
* 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<T extends CMDBObject>(
|
|
typeName: CMDBObjectTypeName,
|
|
id: string,
|
|
options?: GetObjectOptions
|
|
): Promise<T | null> {
|
|
// Force refresh: always fetch from Jira
|
|
if (options?.forceRefresh) {
|
|
return this.fetchAndCacheObject<T>(typeName, id);
|
|
}
|
|
|
|
// Try cache first
|
|
const cached = await cacheStore.getObject<T>(typeName, id);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
// Cache miss: fetch from Jira
|
|
return this.fetchAndCacheObject<T>(typeName, id);
|
|
}
|
|
|
|
/**
|
|
* Get a single object by object key (e.g., "ICMT-123")
|
|
*/
|
|
async getObjectByKey<T extends CMDBObject>(
|
|
typeName: CMDBObjectTypeName,
|
|
objectKey: string,
|
|
options?: GetObjectOptions
|
|
): Promise<T | null> {
|
|
// 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<T>(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<T>(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<T extends CMDBObject>(
|
|
typeName: CMDBObjectTypeName,
|
|
id: string
|
|
): Promise<T | null> {
|
|
try {
|
|
const jiraObj = await jiraAssetsClient.getObject(id);
|
|
if (!jiraObj) return null;
|
|
|
|
const parsed = jiraAssetsClient.parseObject<T>(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<T extends CMDBObject>(
|
|
typeName: CMDBObjectTypeName,
|
|
options?: SearchOptions
|
|
): Promise<T[]> {
|
|
if (options?.searchTerm) {
|
|
return await cacheStore.searchByLabel<T>(typeName, options.searchTerm, {
|
|
limit: options.limit,
|
|
offset: options.offset,
|
|
});
|
|
}
|
|
|
|
return await cacheStore.getObjects<T>(typeName, {
|
|
limit: options?.limit,
|
|
offset: options?.offset,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Count objects of a type in cache
|
|
*/
|
|
async countObjects(typeName: CMDBObjectTypeName): Promise<number> {
|
|
return await cacheStore.countObjects(typeName);
|
|
}
|
|
|
|
/**
|
|
* Search across all object types
|
|
*/
|
|
async searchAllTypes(searchTerm: string, options?: { limit?: number }): Promise<CMDBObject[]> {
|
|
return await cacheStore.searchAllTypes(searchTerm, { limit: options?.limit });
|
|
}
|
|
|
|
/**
|
|
* Get related objects (outbound references)
|
|
*/
|
|
async getRelatedObjects<T extends CMDBObject>(
|
|
sourceId: string,
|
|
attributeName: string,
|
|
targetTypeName: CMDBObjectTypeName
|
|
): Promise<T[]> {
|
|
return await cacheStore.getRelatedObjects<T>(sourceId, targetTypeName, attributeName);
|
|
}
|
|
|
|
/**
|
|
* Get objects that reference the given object (inbound references)
|
|
*/
|
|
async getReferencingObjects<T extends CMDBObject>(
|
|
targetId: string,
|
|
sourceTypeName: CMDBObjectTypeName,
|
|
attributeName?: string
|
|
): Promise<T[]> {
|
|
return await cacheStore.getReferencingObjects<T>(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<string, unknown>,
|
|
originalUpdatedAt: string
|
|
): Promise<UpdateResult> {
|
|
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<string, unknown>
|
|
): Promise<UpdateResult> {
|
|
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<string, unknown>
|
|
): 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<CacheStats> {
|
|
return await cacheStore.getStats();
|
|
}
|
|
|
|
/**
|
|
* Check if cache has data
|
|
*/
|
|
async isCacheWarm(): Promise<boolean> {
|
|
return await cacheStore.isWarm();
|
|
}
|
|
|
|
/**
|
|
* Clear cache for a specific type
|
|
*/
|
|
async clearCacheForType(typeName: CMDBObjectTypeName): Promise<void> {
|
|
await cacheStore.clearObjectType(typeName);
|
|
}
|
|
|
|
/**
|
|
* Clear entire cache
|
|
*/
|
|
async clearCache(): Promise<void> {
|
|
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();
|
|
|