UI styling improvements: dashboard headers and navigation
- 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
This commit is contained in:
817
backend/src/services/SchemaSyncService.ts
Normal file
817
backend/src/services/SchemaSyncService.ts
Normal file
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* Schema Sync Service
|
||||
*
|
||||
* Unified service for synchronizing Jira Assets schema configuration to local database.
|
||||
* Implements the complete sync flow as specified in the refactor plan.
|
||||
*/
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { getDatabaseAdapter } from './database/singleton.js';
|
||||
import type { DatabaseAdapter } from './database/interface.js';
|
||||
import { config } from '../config/env.js';
|
||||
import { toCamelCase, toPascalCase, mapJiraType, determineSyncPriority } from './schemaUtils.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface JiraSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
objectSchemaKey?: string;
|
||||
status?: string;
|
||||
description?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
objectCount?: number;
|
||||
objectTypeCount?: number;
|
||||
}
|
||||
|
||||
interface JiraObjectType {
|
||||
id: number;
|
||||
name: string;
|
||||
type?: number;
|
||||
description?: string;
|
||||
icon?: {
|
||||
id: number;
|
||||
name: string;
|
||||
url16?: string;
|
||||
url48?: string;
|
||||
};
|
||||
position?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
objectCount?: number;
|
||||
parentObjectTypeId?: number | null;
|
||||
objectSchemaId: number;
|
||||
inherited?: boolean;
|
||||
abstractObjectType?: boolean;
|
||||
}
|
||||
|
||||
interface JiraAttribute {
|
||||
id: number;
|
||||
objectType?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
name: string;
|
||||
label?: boolean;
|
||||
type: number;
|
||||
description?: string;
|
||||
defaultType?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
typeValue?: string | null;
|
||||
typeValueMulti?: string[];
|
||||
additionalValue?: string | null;
|
||||
referenceType?: {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
url16?: string | null;
|
||||
removable?: boolean;
|
||||
objectSchemaId?: number;
|
||||
} | null;
|
||||
referenceObjectTypeId?: number | null;
|
||||
referenceObjectType?: {
|
||||
id: number;
|
||||
name: string;
|
||||
objectSchemaId?: number;
|
||||
} | null;
|
||||
editable?: boolean;
|
||||
system?: boolean;
|
||||
sortable?: boolean;
|
||||
summable?: boolean;
|
||||
indexed?: boolean;
|
||||
minimumCardinality?: number;
|
||||
maximumCardinality?: number;
|
||||
suffix?: string;
|
||||
removable?: boolean;
|
||||
hidden?: boolean;
|
||||
includeChildObjectTypes?: boolean;
|
||||
uniqueAttribute?: boolean;
|
||||
regexValidation?: string | null;
|
||||
iql?: string | null;
|
||||
options?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
schemasProcessed: number;
|
||||
objectTypesProcessed: number;
|
||||
attributesProcessed: number;
|
||||
schemasDeleted: number;
|
||||
objectTypesDeleted: number;
|
||||
attributesDeleted: number;
|
||||
errors: SyncError[];
|
||||
duration: number; // milliseconds
|
||||
}
|
||||
|
||||
export interface SyncError {
|
||||
type: 'schema' | 'objectType' | 'attribute';
|
||||
id: string | number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SyncProgress {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
currentSchema?: string;
|
||||
currentObjectType?: string;
|
||||
schemasTotal: number;
|
||||
schemasCompleted: number;
|
||||
objectTypesTotal: number;
|
||||
objectTypesCompleted: number;
|
||||
startedAt?: Date;
|
||||
estimatedCompletion?: Date;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SchemaSyncService Implementation
|
||||
// =============================================================================
|
||||
|
||||
class SchemaSyncService {
|
||||
private db: DatabaseAdapter;
|
||||
private isPostgres: boolean;
|
||||
private baseUrl: string;
|
||||
private progress: SyncProgress = {
|
||||
status: 'idle',
|
||||
schemasTotal: 0,
|
||||
schemasCompleted: 0,
|
||||
objectTypesTotal: 0,
|
||||
objectTypesCompleted: 0,
|
||||
};
|
||||
|
||||
// Rate limiting configuration
|
||||
private readonly RATE_LIMIT_DELAY_MS = 150; // 150ms between requests
|
||||
private readonly MAX_RETRIES = 3;
|
||||
private readonly RETRY_DELAY_MS = 1000;
|
||||
|
||||
constructor() {
|
||||
this.db = getDatabaseAdapter();
|
||||
this.isPostgres = (this.db.isPostgres === true);
|
||||
this.baseUrl = `${config.jiraHost}/rest/assets/1.0`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication headers for API requests
|
||||
*/
|
||||
private getHeaders(): Record<string, string> {
|
||||
const token = config.jiraServiceAccountToken;
|
||||
if (!token) {
|
||||
throw new Error('JIRA_SERVICE_ACCOUNT_TOKEN not configured. Schema sync requires a service account token.');
|
||||
}
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting delay
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with rate limiting and retry logic
|
||||
*/
|
||||
private async fetchWithRateLimit<T>(
|
||||
url: string,
|
||||
retries: number = this.MAX_RETRIES
|
||||
): Promise<T> {
|
||||
await this.delay(this.RATE_LIMIT_DELAY_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
// Handle rate limiting (429)
|
||||
if (response.status === 429) {
|
||||
const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
|
||||
logger.warn(`SchemaSync: Rate limited, waiting ${retryAfter}s before retry`);
|
||||
await this.delay(retryAfter * 1000);
|
||||
return this.fetchWithRateLimit<T>(url, retries);
|
||||
}
|
||||
|
||||
// Handle server errors with retry
|
||||
if (response.status >= 500 && retries > 0) {
|
||||
logger.warn(`SchemaSync: Server error ${response.status}, retrying (${retries} attempts left)`);
|
||||
await this.delay(this.RETRY_DELAY_MS);
|
||||
return this.fetchWithRateLimit<T>(url, retries - 1);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
if (retries > 0 && error instanceof Error && !error.message.includes('HTTP')) {
|
||||
logger.warn(`SchemaSync: Network error, retrying (${retries} attempts left)`, error);
|
||||
await this.delay(this.RETRY_DELAY_MS);
|
||||
return this.fetchWithRateLimit<T>(url, retries - 1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all schemas from Jira
|
||||
*/
|
||||
private async fetchSchemas(): Promise<JiraSchema[]> {
|
||||
const url = `${this.baseUrl}/objectschema/list`;
|
||||
logger.debug(`SchemaSync: Fetching schemas from ${url}`);
|
||||
|
||||
const result = await this.fetchWithRateLimit<{ objectschemas?: JiraSchema[] } | JiraSchema[]>(url);
|
||||
|
||||
// Handle different response formats
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
} else if (result && typeof result === 'object' && 'objectschemas' in result) {
|
||||
return result.objectschemas || [];
|
||||
}
|
||||
|
||||
logger.warn('SchemaSync: Unexpected schema list response format', result);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch schema details
|
||||
*/
|
||||
private async fetchSchemaDetails(schemaId: number): Promise<JiraSchema> {
|
||||
const url = `${this.baseUrl}/objectschema/${schemaId}`;
|
||||
logger.debug(`SchemaSync: Fetching schema details for ${schemaId}`);
|
||||
return await this.fetchWithRateLimit<JiraSchema>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all object types for a schema (flat list)
|
||||
*/
|
||||
private async fetchObjectTypes(schemaId: number): Promise<JiraObjectType[]> {
|
||||
const url = `${this.baseUrl}/objectschema/${schemaId}/objecttypes/flat`;
|
||||
logger.debug(`SchemaSync: Fetching object types for schema ${schemaId}`);
|
||||
|
||||
try {
|
||||
const result = await this.fetchWithRateLimit<JiraObjectType[]>(url);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error) {
|
||||
// Fallback to regular endpoint if flat endpoint fails
|
||||
logger.warn(`SchemaSync: Flat endpoint failed, trying regular endpoint`, error);
|
||||
const fallbackUrl = `${this.baseUrl}/objectschema/${schemaId}/objecttypes`;
|
||||
const fallbackResult = await this.fetchWithRateLimit<{ objectTypes?: JiraObjectType[] } | JiraObjectType[]>(fallbackUrl);
|
||||
|
||||
if (Array.isArray(fallbackResult)) {
|
||||
return fallbackResult;
|
||||
} else if (fallbackResult && typeof fallbackResult === 'object' && 'objectTypes' in fallbackResult) {
|
||||
return fallbackResult.objectTypes || [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch object type details
|
||||
*/
|
||||
private async fetchObjectTypeDetails(typeId: number): Promise<JiraObjectType> {
|
||||
const url = `${this.baseUrl}/objecttype/${typeId}`;
|
||||
logger.debug(`SchemaSync: Fetching object type details for ${typeId}`);
|
||||
return await this.fetchWithRateLimit<JiraObjectType>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch attributes for an object type
|
||||
*/
|
||||
private async fetchAttributes(typeId: number): Promise<JiraAttribute[]> {
|
||||
const url = `${this.baseUrl}/objecttype/${typeId}/attributes`;
|
||||
logger.debug(`SchemaSync: Fetching attributes for object type ${typeId}`);
|
||||
|
||||
try {
|
||||
const result = await this.fetchWithRateLimit<JiraAttribute[]>(url);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error) {
|
||||
logger.warn(`SchemaSync: Failed to fetch attributes for type ${typeId}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Jira attribute to database format
|
||||
*/
|
||||
private parseAttribute(
|
||||
attr: JiraAttribute,
|
||||
allTypeConfigs: Map<number, { name: string; typeName: string }>
|
||||
): {
|
||||
jiraId: number;
|
||||
name: string;
|
||||
fieldName: string;
|
||||
type: string;
|
||||
isMultiple: boolean;
|
||||
isEditable: boolean;
|
||||
isRequired: boolean;
|
||||
isSystem: boolean;
|
||||
referenceTypeName?: string;
|
||||
description?: string;
|
||||
// Additional fields from plan
|
||||
label?: boolean;
|
||||
sortable?: boolean;
|
||||
summable?: boolean;
|
||||
indexed?: boolean;
|
||||
suffix?: string;
|
||||
removable?: boolean;
|
||||
hidden?: boolean;
|
||||
includeChildObjectTypes?: boolean;
|
||||
uniqueAttribute?: boolean;
|
||||
regexValidation?: string | null;
|
||||
iql?: string | null;
|
||||
options?: string;
|
||||
position?: number;
|
||||
} {
|
||||
const typeId = attr.type || attr.defaultType?.id || 0;
|
||||
let type = mapJiraType(typeId);
|
||||
const isMultiple = (attr.maximumCardinality ?? 1) > 1 || attr.maximumCardinality === -1;
|
||||
const isEditable = attr.editable !== false && !attr.hidden;
|
||||
const isRequired = (attr.minimumCardinality ?? 0) > 0;
|
||||
const isSystem = attr.system === true;
|
||||
|
||||
// CRITICAL: Jira sometimes returns type=1 (integer) for reference attributes!
|
||||
// The presence of referenceObjectTypeId is the true indicator of a reference type.
|
||||
const refTypeId = attr.referenceObjectTypeId || attr.referenceObject?.id || attr.referenceType?.id;
|
||||
if (refTypeId) {
|
||||
type = 'reference';
|
||||
}
|
||||
|
||||
const result: ReturnType<typeof this.parseAttribute> = {
|
||||
jiraId: attr.id,
|
||||
name: attr.name,
|
||||
fieldName: toCamelCase(attr.name),
|
||||
type,
|
||||
isMultiple,
|
||||
isEditable,
|
||||
isRequired,
|
||||
isSystem,
|
||||
description: attr.description,
|
||||
label: attr.label,
|
||||
sortable: attr.sortable,
|
||||
summable: attr.summable,
|
||||
indexed: attr.indexed,
|
||||
suffix: attr.suffix,
|
||||
removable: attr.removable,
|
||||
hidden: attr.hidden,
|
||||
includeChildObjectTypes: attr.includeChildObjectTypes,
|
||||
uniqueAttribute: attr.uniqueAttribute,
|
||||
regexValidation: attr.regexValidation,
|
||||
iql: attr.iql,
|
||||
options: attr.options,
|
||||
position: attr.position,
|
||||
};
|
||||
|
||||
// Handle reference types - add reference metadata
|
||||
if (type === 'reference' && refTypeId) {
|
||||
const refConfig = allTypeConfigs.get(refTypeId);
|
||||
result.referenceTypeName = refConfig?.typeName ||
|
||||
attr.referenceObjectType?.name ||
|
||||
attr.referenceType?.name ||
|
||||
`Type${refTypeId}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all schemas and their complete structure
|
||||
*/
|
||||
async syncAll(): Promise<SyncResult> {
|
||||
const startTime = Date.now();
|
||||
const errors: SyncError[] = [];
|
||||
|
||||
this.progress = {
|
||||
status: 'running',
|
||||
schemasTotal: 0,
|
||||
schemasCompleted: 0,
|
||||
objectTypesTotal: 0,
|
||||
objectTypesCompleted: 0,
|
||||
startedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info('SchemaSync: Starting full schema synchronization...');
|
||||
|
||||
// Step 1: Fetch all schemas
|
||||
const schemas = await this.fetchSchemas();
|
||||
this.progress.schemasTotal = schemas.length;
|
||||
logger.info(`SchemaSync: Found ${schemas.length} schemas to sync`);
|
||||
|
||||
if (schemas.length === 0) {
|
||||
throw new Error('No schemas found in Jira Assets');
|
||||
}
|
||||
|
||||
// Track Jira IDs for cleanup
|
||||
const jiraSchemaIds = new Set<string>();
|
||||
const jiraObjectTypeIds = new Map<string, Set<number>>(); // schemaId -> Set<typeId>
|
||||
const jiraAttributeIds = new Map<string, Set<number>>(); // typeName -> Set<attrId>
|
||||
|
||||
let schemasProcessed = 0;
|
||||
let objectTypesProcessed = 0;
|
||||
let attributesProcessed = 0;
|
||||
let schemasDeleted = 0;
|
||||
let objectTypesDeleted = 0;
|
||||
let attributesDeleted = 0;
|
||||
|
||||
await this.db.transaction(async (txDb) => {
|
||||
// Step 2: Process each schema
|
||||
for (const schema of schemas) {
|
||||
try {
|
||||
this.progress.currentSchema = schema.name;
|
||||
const schemaIdStr = schema.id.toString();
|
||||
jiraSchemaIds.add(schemaIdStr);
|
||||
|
||||
// Fetch schema details
|
||||
let schemaDetails: JiraSchema;
|
||||
try {
|
||||
schemaDetails = await this.fetchSchemaDetails(schema.id);
|
||||
} catch (error) {
|
||||
logger.warn(`SchemaSync: Failed to fetch details for schema ${schema.id}, using list data`, error);
|
||||
schemaDetails = schema;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const objectSchemaKey = schemaDetails.objectSchemaKey || schemaDetails.name || schemaIdStr;
|
||||
|
||||
// Upsert schema
|
||||
if (txDb.isPostgres) {
|
||||
await txDb.execute(`
|
||||
INSERT INTO schemas (jira_schema_id, name, object_schema_key, status, description, discovered_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(jira_schema_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
object_schema_key = excluded.object_schema_key,
|
||||
status = excluded.status,
|
||||
description = excluded.description,
|
||||
updated_at = excluded.updated_at
|
||||
`, [
|
||||
schemaIdStr,
|
||||
schemaDetails.name,
|
||||
objectSchemaKey,
|
||||
schemaDetails.status || null,
|
||||
schemaDetails.description || null,
|
||||
now,
|
||||
now,
|
||||
]);
|
||||
} else {
|
||||
await txDb.execute(`
|
||||
INSERT INTO schemas (jira_schema_id, name, object_schema_key, status, description, discovered_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(jira_schema_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
object_schema_key = excluded.object_schema_key,
|
||||
status = excluded.status,
|
||||
description = excluded.description,
|
||||
updated_at = excluded.updated_at
|
||||
`, [
|
||||
schemaIdStr,
|
||||
schemaDetails.name,
|
||||
objectSchemaKey,
|
||||
schemaDetails.status || null,
|
||||
schemaDetails.description || null,
|
||||
now,
|
||||
now,
|
||||
]);
|
||||
}
|
||||
|
||||
// Get schema FK
|
||||
const schemaRow = await txDb.queryOne<{ id: number }>(
|
||||
`SELECT id FROM schemas WHERE jira_schema_id = ?`,
|
||||
[schemaIdStr]
|
||||
);
|
||||
|
||||
if (!schemaRow) {
|
||||
throw new Error(`Failed to get schema FK for ${schemaIdStr}`);
|
||||
}
|
||||
|
||||
const schemaIdFk = schemaRow.id;
|
||||
|
||||
// Step 3: Fetch all object types for this schema
|
||||
const objectTypes = await this.fetchObjectTypes(schema.id);
|
||||
logger.info(`SchemaSync: Found ${objectTypes.length} object types in schema ${schema.name}`);
|
||||
|
||||
const typeConfigs = new Map<number, { name: string; typeName: string }>();
|
||||
jiraObjectTypeIds.set(schemaIdStr, new Set());
|
||||
|
||||
// Build type name mapping
|
||||
for (const objType of objectTypes) {
|
||||
const typeName = toPascalCase(objType.name);
|
||||
typeConfigs.set(objType.id, {
|
||||
name: objType.name,
|
||||
typeName,
|
||||
});
|
||||
jiraObjectTypeIds.get(schemaIdStr)!.add(objType.id);
|
||||
}
|
||||
|
||||
// Step 4: Store object types
|
||||
for (const objType of objectTypes) {
|
||||
try {
|
||||
this.progress.currentObjectType = objType.name;
|
||||
const typeName = toPascalCase(objType.name);
|
||||
const objectCount = objType.objectCount || 0;
|
||||
const syncPriority = determineSyncPriority(objType.name, objectCount);
|
||||
|
||||
// Upsert object type
|
||||
if (txDb.isPostgres) {
|
||||
await txDb.execute(`
|
||||
INSERT INTO object_types (
|
||||
schema_id, jira_type_id, type_name, display_name, description,
|
||||
sync_priority, object_count, enabled, discovered_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(schema_id, jira_type_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
sync_priority = excluded.sync_priority,
|
||||
object_count = excluded.object_count,
|
||||
updated_at = excluded.updated_at
|
||||
`, [
|
||||
schemaIdFk,
|
||||
objType.id,
|
||||
typeName,
|
||||
objType.name,
|
||||
objType.description || null,
|
||||
syncPriority,
|
||||
objectCount,
|
||||
false, // Default: disabled
|
||||
now,
|
||||
now,
|
||||
]);
|
||||
} else {
|
||||
await txDb.execute(`
|
||||
INSERT INTO object_types (
|
||||
schema_id, jira_type_id, type_name, display_name, description,
|
||||
sync_priority, object_count, enabled, discovered_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(schema_id, jira_type_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
description = excluded.description,
|
||||
sync_priority = excluded.sync_priority,
|
||||
object_count = excluded.object_count,
|
||||
updated_at = excluded.updated_at
|
||||
`, [
|
||||
schemaIdFk,
|
||||
objType.id,
|
||||
typeName,
|
||||
objType.name,
|
||||
objType.description || null,
|
||||
syncPriority,
|
||||
objectCount,
|
||||
0, // Default: disabled (0 = false in SQLite)
|
||||
now,
|
||||
now,
|
||||
]);
|
||||
}
|
||||
|
||||
objectTypesProcessed++;
|
||||
|
||||
// Step 5: Fetch and store attributes
|
||||
const attributes = await this.fetchAttributes(objType.id);
|
||||
logger.info(`SchemaSync: Fetched ${attributes.length} attributes for ${objType.name} (type ${objType.id})`);
|
||||
|
||||
if (!jiraAttributeIds.has(typeName)) {
|
||||
jiraAttributeIds.set(typeName, new Set());
|
||||
}
|
||||
|
||||
if (attributes.length === 0) {
|
||||
logger.warn(`SchemaSync: No attributes found for ${objType.name} (type ${objType.id})`);
|
||||
}
|
||||
|
||||
for (const jiraAttr of attributes) {
|
||||
try {
|
||||
const attrDef = this.parseAttribute(jiraAttr, typeConfigs);
|
||||
jiraAttributeIds.get(typeName)!.add(attrDef.jiraId);
|
||||
|
||||
// Upsert attribute
|
||||
if (txDb.isPostgres) {
|
||||
await txDb.execute(`
|
||||
INSERT INTO attributes (
|
||||
jira_attr_id, object_type_name, attr_name, field_name, attr_type,
|
||||
is_multiple, is_editable, is_required, is_system,
|
||||
reference_type_name, description, position, discovered_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(jira_attr_id, object_type_name) DO UPDATE SET
|
||||
attr_name = excluded.attr_name,
|
||||
field_name = excluded.field_name,
|
||||
attr_type = excluded.attr_type,
|
||||
is_multiple = excluded.is_multiple,
|
||||
is_editable = excluded.is_editable,
|
||||
is_required = excluded.is_required,
|
||||
is_system = excluded.is_system,
|
||||
reference_type_name = excluded.reference_type_name,
|
||||
description = excluded.description,
|
||||
position = excluded.position
|
||||
`, [
|
||||
attrDef.jiraId,
|
||||
typeName,
|
||||
attrDef.name,
|
||||
attrDef.fieldName,
|
||||
attrDef.type,
|
||||
attrDef.isMultiple,
|
||||
attrDef.isEditable,
|
||||
attrDef.isRequired,
|
||||
attrDef.isSystem,
|
||||
attrDef.referenceTypeName || null,
|
||||
attrDef.description || null,
|
||||
attrDef.position ?? 0,
|
||||
now,
|
||||
]);
|
||||
} else {
|
||||
await txDb.execute(`
|
||||
INSERT INTO attributes (
|
||||
jira_attr_id, object_type_name, attr_name, field_name, attr_type,
|
||||
is_multiple, is_editable, is_required, is_system,
|
||||
reference_type_name, description, position, discovered_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(jira_attr_id, object_type_name) DO UPDATE SET
|
||||
attr_name = excluded.attr_name,
|
||||
field_name = excluded.field_name,
|
||||
attr_type = excluded.attr_type,
|
||||
is_multiple = excluded.is_multiple,
|
||||
is_editable = excluded.is_editable,
|
||||
is_required = excluded.is_required,
|
||||
is_system = excluded.is_system,
|
||||
reference_type_name = excluded.reference_type_name,
|
||||
description = excluded.description,
|
||||
position = excluded.position
|
||||
`, [
|
||||
attrDef.jiraId,
|
||||
typeName,
|
||||
attrDef.name,
|
||||
attrDef.fieldName,
|
||||
attrDef.type,
|
||||
attrDef.isMultiple ? 1 : 0,
|
||||
attrDef.isEditable ? 1 : 0,
|
||||
attrDef.isRequired ? 1 : 0,
|
||||
attrDef.isSystem ? 1 : 0,
|
||||
attrDef.referenceTypeName || null,
|
||||
attrDef.description || null,
|
||||
attrDef.position ?? 0,
|
||||
now,
|
||||
]);
|
||||
}
|
||||
|
||||
attributesProcessed++;
|
||||
} catch (error) {
|
||||
logger.error(`SchemaSync: Failed to process attribute ${jiraAttr.id} (${jiraAttr.name}) for ${objType.name}`, error);
|
||||
if (error instanceof Error) {
|
||||
logger.error(`SchemaSync: Attribute error details: ${error.message}`, error.stack);
|
||||
}
|
||||
errors.push({
|
||||
type: 'attribute',
|
||||
id: jiraAttr.id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`SchemaSync: Processed ${attributesProcessed} attributes for ${objType.name} (type ${objType.id})`);
|
||||
this.progress.objectTypesCompleted++;
|
||||
} catch (error) {
|
||||
logger.warn(`SchemaSync: Failed to process object type ${objType.id}`, error);
|
||||
errors.push({
|
||||
type: 'objectType',
|
||||
id: objType.id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.progress.schemasCompleted++;
|
||||
schemasProcessed++;
|
||||
} catch (error) {
|
||||
logger.error(`SchemaSync: Failed to process schema ${schema.id}`, error);
|
||||
errors.push({
|
||||
type: 'schema',
|
||||
id: schema.id.toString(),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Clean up orphaned records (hard delete)
|
||||
logger.info('SchemaSync: Cleaning up orphaned records...');
|
||||
|
||||
// Delete orphaned schemas
|
||||
const allLocalSchemas = await txDb.query<{ jira_schema_id: string }>(
|
||||
`SELECT jira_schema_id FROM schemas`
|
||||
);
|
||||
for (const localSchema of allLocalSchemas) {
|
||||
if (!jiraSchemaIds.has(localSchema.jira_schema_id)) {
|
||||
logger.info(`SchemaSync: Deleting orphaned schema ${localSchema.jira_schema_id}`);
|
||||
await txDb.execute(`DELETE FROM schemas WHERE jira_schema_id = ?`, [localSchema.jira_schema_id]);
|
||||
schemasDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned object types
|
||||
// First, get all object types from all remaining schemas
|
||||
const allLocalObjectTypes = await txDb.query<{ schema_id: number; jira_type_id: number; jira_schema_id: string }>(
|
||||
`SELECT ot.schema_id, ot.jira_type_id, s.jira_schema_id
|
||||
FROM object_types ot
|
||||
JOIN schemas s ON ot.schema_id = s.id`
|
||||
);
|
||||
|
||||
for (const localType of allLocalObjectTypes) {
|
||||
const schemaIdStr = localType.jira_schema_id;
|
||||
const typeIds = jiraObjectTypeIds.get(schemaIdStr);
|
||||
|
||||
// If schema doesn't exist in Jira anymore, or type doesn't exist in schema
|
||||
if (!jiraSchemaIds.has(schemaIdStr) || (typeIds && !typeIds.has(localType.jira_type_id))) {
|
||||
logger.info(`SchemaSync: Deleting orphaned object type ${localType.jira_type_id} from schema ${schemaIdStr}`);
|
||||
await txDb.execute(
|
||||
`DELETE FROM object_types WHERE schema_id = ? AND jira_type_id = ?`,
|
||||
[localType.schema_id, localType.jira_type_id]
|
||||
);
|
||||
objectTypesDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned attributes
|
||||
// Get all attributes and check against synced types
|
||||
const allLocalAttributes = await txDb.query<{ object_type_name: string; jira_attr_id: number }>(
|
||||
`SELECT object_type_name, jira_attr_id FROM attributes`
|
||||
);
|
||||
|
||||
for (const localAttr of allLocalAttributes) {
|
||||
const attrIds = jiraAttributeIds.get(localAttr.object_type_name);
|
||||
|
||||
// If type wasn't synced or attribute doesn't exist in type
|
||||
if (!attrIds || !attrIds.has(localAttr.jira_attr_id)) {
|
||||
logger.info(`SchemaSync: Deleting orphaned attribute ${localAttr.jira_attr_id} from type ${localAttr.object_type_name}`);
|
||||
await txDb.execute(
|
||||
`DELETE FROM attributes WHERE object_type_name = ? AND jira_attr_id = ?`,
|
||||
[localAttr.object_type_name, localAttr.jira_attr_id]
|
||||
);
|
||||
attributesDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`SchemaSync: Cleanup complete - ${schemasDeleted} schemas, ${objectTypesDeleted} object types, ${attributesDeleted} attributes deleted`);
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.progress.status = 'completed';
|
||||
|
||||
logger.info(`SchemaSync: Synchronization complete in ${duration}ms - ${schemasProcessed} schemas, ${objectTypesProcessed} object types, ${attributesProcessed} attributes, ${schemasDeleted} deleted schemas, ${objectTypesDeleted} deleted types, ${attributesDeleted} deleted attributes`);
|
||||
|
||||
if (attributesProcessed === 0) {
|
||||
logger.warn(`SchemaSync: WARNING - No attributes were saved! Check logs for errors.`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.warn(`SchemaSync: Sync completed with ${errors.length} errors:`, errors);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
schemasProcessed,
|
||||
objectTypesProcessed,
|
||||
attributesProcessed,
|
||||
schemasDeleted,
|
||||
objectTypesDeleted,
|
||||
attributesDeleted,
|
||||
errors,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
this.progress.status = 'failed';
|
||||
logger.error('SchemaSync: Synchronization failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single schema by ID
|
||||
*/
|
||||
async syncSchema(schemaId: number): Promise<SyncResult> {
|
||||
// For single schema sync, we can reuse syncAll logic but filter
|
||||
// For now, just call syncAll (it's idempotent)
|
||||
logger.info(`SchemaSync: Syncing single schema ${schemaId}`);
|
||||
return this.syncAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status/progress
|
||||
*/
|
||||
getProgress(): SyncProgress {
|
||||
return { ...this.progress };
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const schemaSyncService = new SchemaSyncService();
|
||||
Reference in New Issue
Block a user