- Restore blue PageHeader on Dashboard (/app-components) - Update homepage (/) with subtle header design without blue bar - Add uniform PageHeader styling to application edit page - Fix Rapporten link on homepage to point to /reports overview - Improve header descriptions spacing for better readability
257 lines
8.9 KiB
TypeScript
257 lines
8.9 KiB
TypeScript
/**
|
|
* 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<string, ObjectTypeWithLinks>;
|
|
cacheCounts?: Record<string, number>;
|
|
jiraCounts?: Record<string, number>;
|
|
}
|
|
|
|
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<SchemaResponse> {
|
|
// 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<SchemaResponse> {
|
|
// 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<string, ObjectTypeWithLinks> = {};
|
|
|
|
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<string, number> | 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();
|