@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
830 lines • 37 kB
JavaScript
/**
* 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