UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

830 lines 37 kB
/** * Incremental Sync Manager for Optimizely MCP Server * @description Manages incremental synchronization using change history */ import { getLogger } from '../logging/Logger.js'; import { MCPErrorMapper } from '../errors/MCPErrorMapping.js'; import { safeIdToString } from '../utils/SafeIdConverter.js'; /** * Manages incremental synchronization of Optimizely data */ export class IncrementalSyncManager { changeTracker; cache; storage; api; constructor(changeTracker, cache, storage, api) { this.changeTracker = changeTracker; this.cache = cache; this.storage = storage; this.api = api; } /** * Gets the project type information from the database */ async getProjectType(projectId) { const result = await this.storage.get('SELECT platform, is_flags_enabled FROM projects WHERE id = ?', [projectId] // Use string project_id to match the TEXT column type ); return { platform: result?.platform || null, is_flags_enabled: result?.is_flags_enabled === 1 }; } /** * Performs incremental sync for a project */ async syncProject(projectId) { const startTime = Date.now(); const errors = []; let synced = 0; let created = 0; let updated = 0; let deleted = 0; let changesProcessed = 0; try { getLogger().info({ projectId }, 'IncrementalSyncManager: Starting incremental sync'); // Get last sync time BEFORE updating sync state let syncState = await this.changeTracker.getSyncState(projectId); // If no sync state exists, we need to run a full sync first if (!syncState) { throw new Error(`No sync state found for project ${projectId}. ` + `Please run a full sync first using refresh_cache without the incremental flag.`); } const lastSync = new Date(syncState.last_sync_time); // Mark sync as in progress await this.changeTracker.updateSyncState(projectId, { sync_in_progress: true }); getLogger().info({ projectId, lastSync: lastSync.toISOString() }, 'IncrementalSyncManager: Last sync time'); // Get project type for logging const projectType = await this.getProjectType(projectId); getLogger().info({ projectId, platform: projectType.platform, is_flags_enabled: projectType.is_flags_enabled }, 'IncrementalSyncManager: Project type detected'); // All projects now use the same change history API approach // The difference is in entity-specific sync methods, not change detection const changes = await this.changeTracker.getChangesSince(projectId, lastSync); changesProcessed = changes.length; if (changes.length === 0) { getLogger().info({ projectId }, 'IncrementalSyncManager: No changes to sync'); // Update sync state await this.changeTracker.updateSyncState(projectId, { last_sync_time: new Date().toISOString(), last_successful_sync: new Date().toISOString(), sync_in_progress: false }); return { projectId, success: true, message: 'No changes to sync', changesProcessed: 0, recordsUpdated: 0, recordsCreated: 0, recordsDeleted: 0, errors: [], duration: Date.now() - startTime, syncTimestamp: new Date().toISOString() }; } // Record all changes first await this.changeTracker.recordChanges(changes); // Deduplicate to get unique entities const uniqueEntities = await this.changeTracker.deduplicateChanges(changes); // Sync each unique entity for (const [entityType, entityIds] of uniqueEntities) { for (const entityId of entityIds) { try { // Find the most recent change for this entity const entityChanges = changes.filter(c => c.entity_type === entityType && c.entity_id === entityId); const latestChange = entityChanges.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0]; getLogger().info({ entityType, entityId, action: latestChange.action }, 'IncrementalSyncManager: Syncing entity'); if (latestChange.action === 'deleted' || latestChange.action === 'archived') { await this.handleDeletedEntity(projectId, entityType, entityId); deleted++; } else { const result = await this.syncSingleEntity(projectId, entityType, entityId, latestChange.entity_name); if (result.created) { created++; } else { updated++; } } synced++; // Mark changes as synced await this.changeTracker.markChangesSynced(projectId, entityType, entityId); } catch (error) { errors.push(error); // Categorize error types for better debugging const errorCategory = this.categorizeError(error); getLogger().error({ entityType, entityId, error: error.message, errorCategory, statusCode: error.status || error.statusCode }, 'IncrementalSyncManager: Failed to sync entity'); // Don't fail entire sync for expected errors if (errorCategory === 'not_found' || errorCategory === 'permission_denied') { getLogger().info({ entityType, entityId, errorCategory }, 'IncrementalSyncManager: Skipping entity due to expected error'); } } } } // Update sync state const newSyncTime = new Date().toISOString(); // Consider sync successful if we processed at least one entity successfully // This prevents single entity failures from blocking sync progress tracking const syncSuccessful = synced > 0 || (changesProcessed === 0); // Log warning if partial success if (syncSuccessful && errors.length > 0) { getLogger().warn({ projectId, totalEntities: changesProcessed, successfulEntities: synced, failedEntities: errors.length, successRate: `${Math.round((synced / changesProcessed) * 100)}%` }, 'IncrementalSyncManager: Partial sync success - some entities failed'); } await this.changeTracker.updateSyncState(projectId, { last_sync_time: newSyncTime, last_successful_sync: syncSuccessful ? newSyncTime : syncState?.last_successful_sync, sync_in_progress: false, error_count: errors.length }); const duration = Date.now() - startTime; getLogger().info({ projectId, duration, changesProcessed, synced, created, updated, deleted, errors: errors.length }, 'IncrementalSyncManager: Sync completed'); return { projectId, success: errors.length === 0, changesProcessed, recordsCreated: created, recordsUpdated: updated, recordsDeleted: deleted, errors, duration, syncTimestamp: newSyncTime }; } catch (error) { const duration = Date.now() - startTime; // Update sync state with error await this.changeTracker.updateSyncState(projectId, { sync_in_progress: false, error_count: (await this.changeTracker.getSyncState(projectId))?.error_count || 0 + 1, last_error: error.message }); getLogger().error({ projectId, error: error.message }, 'IncrementalSyncManager: Sync failed'); throw MCPErrorMapper.toMCPError(error, 'Incremental sync failed'); } } /** * Syncs a single entity by fetching its latest state */ async syncSingleEntity(projectId, entityType, entityId, entityName) { switch (entityType) { case 'flag': // For flags, entityName is the flag key we need return await this.syncFlag(projectId, entityName || entityId); case 'feature': return await this.syncFeature(entityId); case 'experiment': return await this.syncExperiment(entityId); case 'campaign': return await this.syncCampaign(projectId, entityId); case 'audience': return await this.syncAudience(entityId); case 'attribute': return await this.syncAttribute(entityId); case 'event': return await this.syncEvent(entityId); case 'page': return await this.syncPage(entityId); case 'extension': return await this.syncExtension(projectId, entityId); case 'webhook': return await this.syncWebhook(projectId, entityId); case 'list_attribute': return await this.syncListAttribute(projectId, entityId); case 'environment': return await this.syncEnvironment(projectId, entityId); case 'collaborator': // For collaborators, entityName contains the user_id we need return await this.syncCollaborator(projectId, entityName || entityId); case 'group': return await this.syncGroup(entityId); default: throw new Error(`Unknown entity type: ${entityType}`); } } /** * Handles deleted or archived entities */ async handleDeletedEntity(projectId, entityType, entityId) { switch (entityType) { case 'flag': await this.storage.run('UPDATE flags SET archived = 1 WHERE project_id = ? AND (key = ? OR id = ?)', [projectId, entityId, entityId] // Use string project_id to match the TEXT column type ); break; case 'feature': await this.storage.run('UPDATE features SET archived = 1 WHERE id = ?', [entityId]); break; case 'experiment': await this.storage.run('UPDATE experiments SET archived = 1 WHERE id = ?', [entityId]); break; case 'campaign': await this.storage.run('UPDATE campaigns SET archived = 1 WHERE id = ?', [entityId]); break; case 'audience': await this.storage.run('UPDATE audiences SET archived = 1 WHERE id = ?', [entityId]); break; case 'extension': await this.storage.run('DELETE FROM extensions WHERE id = ?', [entityId]); break; case 'webhook': await this.storage.run('DELETE FROM webhooks WHERE id = ?', [entityId]); break; case 'attribute': await this.storage.run('UPDATE attributes SET archived = 1 WHERE id = ?', [entityId]); break; case 'event': await this.storage.run('UPDATE events SET archived = 1 WHERE id = ?', [entityId]); break; case 'page': await this.storage.run('UPDATE pages SET archived = 1 WHERE id = ?', [entityId]); break; case 'list_attribute': await this.storage.run('UPDATE list_attributes SET archived = 1 WHERE id = ?', [entityId]); break; case 'environment': await this.storage.run('UPDATE environments SET archived = 1 WHERE id = ?', [entityId]); break; case 'collaborator': await this.storage.run('DELETE FROM collaborators WHERE user_id = ? AND project_id = ?', [entityId, projectId] // Use string project_id to match the TEXT column type ); break; case 'group': await this.storage.run('UPDATE groups SET archived = 1 WHERE id = ?', [entityId]); break; } } /** * Syncs a single flag */ async syncFlag(projectId, flagKey) { // Check if this is a Feature Experimentation project const project = await this.storage.get('SELECT is_flags_enabled FROM projects WHERE id = ?', [projectId] // Use string project_id to match the TEXT column type ); if (!project || !project.is_flags_enabled) { getLogger().warn({ projectId, flagKey }, 'IncrementalSyncManager: Skipping flag sync - project is not Feature Experimentation'); return { created: false }; } let flag; try { flag = await this.api.getFlag(projectId, flagKey); } catch (error) { if (error.status === 404) { // Flag was deleted - mark as archived in our cache await this.handleDeletedEntity(projectId, 'flag', flagKey); return { created: false }; } throw error; } // Check if flag exists const existing = await this.storage.get('SELECT 1 FROM flags WHERE project_id = ? AND key = ?', [projectId, flagKey] // Use string project_id to match the TEXT column type ); await this.storage.run(` INSERT OR REPLACE INTO flags (project_id, key, id, name, description, archived, created_time, updated_time, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ projectId, flag.key, // CRITICAL: Use SafeIdConverter to prevent scientific notation in flag IDs safeIdToString(flag.id), flag.name || '', flag.description || '', flag.archived ? 1 : 0, flag.created_time || null, flag.updated_time || null, JSON.stringify(flag) ]); // Also sync flag environments const environments = await this.storage.query('SELECT key FROM environments WHERE project_id = ?', [projectId] // Use string project_id to match the TEXT column type ); for (const env of environments) { try { const ruleset = await this.api.getFlagEnvironmentRuleset(projectId, flag.key, env.key); await this.storage.run(` INSERT OR REPLACE INTO flag_environments (project_id, flag_key, environment_key, enabled, rules_summary, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ projectId, flag.key, env.key, ruleset.enabled ? 1 : 0, JSON.stringify(ruleset.rules_summary || {}), JSON.stringify(ruleset) ]); } catch (error) { if (error.status !== 404) { throw error; } } } return { created: !existing }; } /** * Syncs a single experiment */ async syncExperiment(experimentId) { const experiment = await this.api.getExperiment(experimentId); // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [safeIdToString(experiment.project_id)]); if (!projectExists) { getLogger().warn({ experimentId, projectId: experiment.project_id }, 'IncrementalSyncManager: Skipping experiment - project not in database'); return { created: false }; } // Check if experiment exists const existing = await this.storage.get('SELECT 1 FROM experiments WHERE id = ?', [experimentId]); await this.storage.run(` INSERT OR REPLACE INTO experiments (id, project_id, name, description, status, flag_key, environment, type, archived, created_time, updated_time, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ // CRITICAL: Use SafeIdConverter to prevent scientific notation in IDs safeIdToString(experiment.id), safeIdToString(experiment.project_id), experiment.name || '', experiment.description || '', experiment.status, experiment.flag_key, experiment.environment_key || experiment.environment, experiment.type, experiment.archived ? 1 : 0, experiment.created_time || experiment.created, experiment.last_modified || experiment.updated_time, JSON.stringify(experiment) ]); return { created: !existing }; } /** * Syncs a single feature */ async syncFeature(featureId) { const feature = await this.api.getFeature(featureId); // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [safeIdToString(feature.project_id)]); if (!projectExists) { getLogger().warn({ featureId, projectId: feature.project_id }, 'IncrementalSyncManager: Skipping feature - project not in database'); return { created: false }; } // Check if feature exists const existing = await this.storage.get('SELECT 1 FROM features WHERE id = ?', [featureId]); await this.storage.run(` INSERT OR REPLACE INTO features (id, project_id, key, name, description, archived, created, last_modified, variable_definitions, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ // CRITICAL: Use SafeIdConverter to prevent scientific notation in IDs safeIdToString(feature.id), safeIdToString(feature.project_id), feature.key || '', feature.name || '', feature.description || '', feature.archived ? 1 : 0, feature.created, feature.last_modified, feature.variable_definitions ? JSON.stringify(feature.variable_definitions) : null, JSON.stringify(feature) ]); return { created: !existing }; } /** * Syncs a single campaign */ async syncCampaign(projectId, campaignId) { const campaign = await this.api.getCampaign(projectId, campaignId); // Campaign might belong to a different project const actualProjectId = campaign.project_id || projectId; // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [safeIdToString(actualProjectId)]); if (!projectExists) { getLogger().warn({ campaignId, projectId: actualProjectId }, 'IncrementalSyncManager: Skipping campaign - project not in database'); return { created: false }; } // Check if campaign exists const existing = await this.storage.get('SELECT 1 FROM campaigns WHERE id = ?', [campaignId]); await this.storage.run(` INSERT OR REPLACE INTO campaigns (id, project_id, name, description, holdback, archived, created_time, updated_time, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ // CRITICAL: Use SafeIdConverter to prevent scientific notation in IDs safeIdToString(campaign.id), safeIdToString(actualProjectId), campaign.name || '', campaign.description || '', campaign.holdback, campaign.archived ? 1 : 0, campaign.created_time || campaign.created, campaign.updated_time || campaign.last_modified, JSON.stringify(campaign) ]); return { created: !existing }; } /** * Syncs a single audience */ async syncAudience(audienceId) { const audience = await this.api.getAudience(audienceId); // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [safeIdToString(audience.project_id)]); if (!projectExists) { getLogger().warn({ audienceId, projectId: audience.project_id }, 'IncrementalSyncManager: Skipping audience - project not in database'); return { created: false }; } // Check if audience exists const existing = await this.storage.get('SELECT 1 FROM audiences WHERE id = ?', [audienceId]); await this.storage.run(` INSERT OR REPLACE INTO audiences (id, project_id, name, description, conditions, archived, created_time, last_modified, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ // CRITICAL: Use SafeIdConverter to prevent scientific notation in IDs safeIdToString(audience.id), safeIdToString(audience.project_id), audience.name || '', audience.description || '', typeof audience.conditions === 'string' ? audience.conditions : JSON.stringify(audience.conditions), audience.archived ? 1 : 0, audience.created_time || audience.created, audience.last_modified, JSON.stringify(audience) ]); return { created: !existing }; } /** * Syncs a single attribute */ async syncAttribute(attributeId) { const attribute = await this.api.getAttribute(attributeId); // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [safeIdToString(attribute.project_id)]); if (!projectExists) { getLogger().warn({ attributeId, projectId: attribute.project_id }, 'IncrementalSyncManager: Skipping attribute - project not in database'); return { created: false }; } // Check if attribute exists const existing = await this.storage.get('SELECT 1 FROM attributes WHERE id = ?', [attributeId]); await this.storage.run(` INSERT OR REPLACE INTO attributes (id, project_id, key, name, condition_type, archived, last_modified, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ safeIdToString(attribute.id), // CRITICAL: Use SafeIdConverter to prevent scientific notation in project IDs safeIdToString(attribute.project_id), attribute.key, attribute.name || '', attribute.condition_type, attribute.archived ? 1 : 0, attribute.last_modified, JSON.stringify(attribute) ]); return { created: !existing }; } /** * Syncs a single event */ async syncEvent(eventId) { const event = await this.api.getEvent(eventId); // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [safeIdToString(event.project_id)]); if (!projectExists) { getLogger().warn({ eventId, projectId: event.project_id }, 'IncrementalSyncManager: Skipping event - project not in database'); return { created: false }; } // Check if event exists const existing = await this.storage.get('SELECT 1 FROM events WHERE id = ?', [eventId]); await this.storage.run(` INSERT OR REPLACE INTO events (id, project_id, key, name, description, event_type, category, archived, created_time, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ // CRITICAL: Use SafeIdConverter to prevent scientific notation in IDs safeIdToString(event.id), safeIdToString(event.project_id), event.key, event.name || '', event.description || '', event.event_type, event.category, event.archived ? 1 : 0, event.created_time || event.created, JSON.stringify(event) ]); return { created: !existing }; } /** * Syncs a single page */ async syncPage(pageId) { const page = await this.api.getPage(pageId); // Page might belong to a different project const actualProjectId = page.project_id; // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [String(actualProjectId)]); if (!projectExists) { getLogger().warn({ pageId, projectId: actualProjectId }, 'IncrementalSyncManager: Skipping page - project not in database'); return { created: false }; } // Check if page exists const existing = await this.storage.get('SELECT 1 FROM pages WHERE id = ?', [pageId]); await this.storage.run(` INSERT OR REPLACE INTO pages (id, project_id, name, edit_url, activation_mode, activation_code, conditions, archived, created_time, updated_time, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ String(page.id), String(actualProjectId), page.name || '', page.edit_url || '', page.activation_mode || '', page.activation_code || '', typeof page.conditions === 'string' ? page.conditions : JSON.stringify(page.conditions || {}), page.archived ? 1 : 0, page.created_time || page.created, page.updated_time || page.last_modified, JSON.stringify(page) ]); return { created: !existing }; } /** * Syncs a single extension */ async syncExtension(projectId, extensionId) { const extension = await this.api.getExtension(extensionId); // Check if extension exists const existing = await this.storage.get('SELECT 1 FROM extensions WHERE id = ?', [extensionId]); await this.storage.run(` INSERT OR REPLACE INTO extensions (id, project_id, name, description, extension_type, implementation, enabled, created_time, updated_time, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ String(extension.id), projectId, extension.name || '', extension.description || '', extension.extension_type || '', extension.implementation || '', extension.enabled ? 1 : 0, extension.created_time || extension.created, extension.updated_time || extension.last_modified, JSON.stringify(extension) ]); return { created: !existing }; } /** * Syncs a single webhook */ async syncWebhook(projectId, webhookId) { const webhook = await this.api.getWebhook(webhookId); // Check if webhook exists const existing = await this.storage.get('SELECT 1 FROM webhooks WHERE id = ?', [webhookId]); await this.storage.run(` INSERT OR REPLACE INTO webhooks (id, project_id, name, url, event_types, enabled, headers, secret, created_time, updated_time, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ String(webhook.id), projectId, webhook.name || '', webhook.url || '', JSON.stringify(webhook.event_types || []), webhook.enabled ? 1 : 0, webhook.headers ? JSON.stringify(webhook.headers) : null, webhook.secret || null, webhook.created_time || webhook.created, webhook.updated_time || webhook.last_modified, JSON.stringify(webhook) ]); return { created: !existing }; } /** * Syncs a single list attribute */ async syncListAttribute(projectId, listAttributeId) { const listAttribute = await this.api.getListAttribute(listAttributeId); // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [String(listAttribute.project_id || projectId)]); if (!projectExists) { getLogger().warn({ listAttributeId, projectId: listAttribute.project_id || projectId }, 'IncrementalSyncManager: Skipping list attribute - project not in database'); return { created: false }; } // Check if list attribute exists const existing = await this.storage.get('SELECT 1 FROM list_attributes WHERE id = ?', [listAttributeId]); await this.storage.run(` INSERT OR REPLACE INTO list_attributes (id, project_id, name, description, key_field, list_type, list_content, created_time, updated_time, archived, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ // CRITICAL: Use SafeIdConverter to prevent scientific notation in IDs safeIdToString(listAttribute.id), safeIdToString(listAttribute.project_id || projectId), listAttribute.name || '', listAttribute.description || '', listAttribute.key_field || '', listAttribute.list_type || '', listAttribute.list_content ? JSON.stringify(listAttribute.list_content) : null, listAttribute.created_time || listAttribute.created, listAttribute.updated_time || listAttribute.last_modified, listAttribute.archived ? 1 : 0, JSON.stringify(listAttribute) ]); return { created: !existing }; } /** * Syncs a single environment */ async syncEnvironment(projectId, environmentKey) { const environment = await this.api.getEnvironment(projectId, environmentKey); // Check if environment exists const existing = await this.storage.get('SELECT 1 FROM environments WHERE project_id = ? AND key = ?', [projectId, environmentKey] // Use string project_id to match the TEXT column type ); await this.storage.run(` INSERT OR REPLACE INTO environments (project_id, key, id, name, description, default_environment, created_time, updated_time, archived, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ // CRITICAL: Use SafeIdConverter to prevent scientific notation in project IDs safeIdToString(projectId), environment.key, safeIdToString(environment.id || environment.key), environment.name || '', environment.description || '', environment.default_environment ? 1 : 0, environment.created_time || environment.created, environment.updated_time || environment.last_modified, environment.archived ? 1 : 0, JSON.stringify(environment) ]); return { created: !existing }; } /** * Syncs a single collaborator */ async syncCollaborator(projectId, userId) { const collaborator = await this.api.getCollaborator(projectId, userId); // Check if collaborator exists const existing = await this.storage.get('SELECT 1 FROM collaborators WHERE user_id = ? AND project_id = ?', [userId, projectId] // Use string project_id to match the TEXT column type ); await this.storage.run(` INSERT OR REPLACE INTO collaborators (user_id, project_id, email, name, role, permissions_json, invited_at, last_seen_at, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ String(collaborator.user_id || userId), projectId, collaborator.email || '', collaborator.name || '', collaborator.role || '', collaborator.permissions ? JSON.stringify(collaborator.permissions) : null, collaborator.invited_at || collaborator.created_time, collaborator.last_seen_at || collaborator.last_modified, JSON.stringify(collaborator) ]); return { created: !existing }; } /** * Categorizes sync errors for better handling */ categorizeError(error) { const status = error.status || error.statusCode; const message = error.message?.toLowerCase() || ''; if (status === 404 || message.includes('not found')) { return 'not_found'; } else if (status === 403 || status === 401) { return 'permission_denied'; } else if (message.includes('timeout') || message.includes('enotfound') || message.includes('network')) { return 'network_error'; } else if (status === 429) { return 'rate_limit'; } else if (status >= 500) { return 'server_error'; } else if (message.includes('constraint') || message.includes('foreign key')) { return 'data_integrity'; } return 'unknown'; } /** * Syncs a single group */ async syncGroup(groupId) { const group = await this.api.getGroup(groupId); // Check if the project exists in our database const projectExists = await this.storage.get('SELECT 1 FROM projects WHERE id = ?', [String(group.project_id)]); if (!projectExists) { getLogger().warn({ groupId, projectId: group.project_id }, 'IncrementalSyncManager: Skipping group - project not in database'); return { created: false }; } // Check if group exists const existing = await this.storage.get('SELECT 1 FROM groups WHERE id = ?', [groupId]); await this.storage.run(` INSERT OR REPLACE INTO groups (id, project_id, name, description, type, policy, traffic_allocation, archived, created_time, updated_time, data_json, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, [ // CRITICAL: Use SafeIdConverter to prevent scientific notation in IDs safeIdToString(group.id), safeIdToString(group.project_id), group.name || '', group.description || '', group.type || '', group.policy || '', group.traffic_allocation || 0, group.archived ? 1 : 0, group.created_time || group.created, group.updated_time || group.last_modified, JSON.stringify(group) ]); return { created: !existing }; } } //# sourceMappingURL=IncrementalSyncManager.js.map