UNPKG

delta-sync

Version:

A lightweight framework for bi-directional database synchronization with automatic version tracking and conflict resolution.

487 lines (486 loc) 17.7 kB
// core/SyncEngine.ts import { SyncView, SyncStatus, } from './types'; import { Coordinator } from './Coordinator'; export class SyncEngine { constructor(localAdapter, options = {}) { this.syncStatus = SyncStatus.OFFLINE; this.isInitialized = false; this.localCoordinator = new Coordinator(localAdapter); this.options = this.mergeDefaultOptions(options); console.log('Powered by Delta Sync 0.1.4'); } mergeDefaultOptions(options) { return { autoSync: { enabled: false, pullInterval: 60000, pushDebounce: 10000, retryDelay: 3000, ...options.autoSync }, maxRetries: 3, timeout: 30000, batchSize: 100, maxFileSize: 10 * 1024 * 1024, // 10MB fileChunkSize: 1024 * 1024, // 1MB ...options }; } async initialize() { if (this.isInitialized) return; try { if (this.options.autoSync?.enabled) { this.enableAutoSync(this.options.autoSync.pullInterval); } this.isInitialized = true; } catch (error) { console.error('初始化同步引擎失败:', error); throw error; } } async ensureInitialized() { if (!this.isInitialized) { await this.initialize(); } } async setCloudAdapter(cloudAdapter) { this.updateStatus(SyncStatus.IDLE); this.cloudCoordinator = new Coordinator(cloudAdapter); } // 数据操作方法 async save(storeName, data) { await this.ensureInitialized(); try { const items = Array.isArray(data) ? data : [data]; const savedItems = await this.localCoordinator.putBulk(storeName, items); this.handleDataChange(); return savedItems; } catch (error) { console.error('Save operation failed:', error); throw error; } } async delete(storeName, ids) { await this.ensureInitialized(); try { const idsToDelete = Array.isArray(ids) ? ids : [ids]; await this.localCoordinator.deleteBulk(storeName, idsToDelete); this.handleDataChange(); } catch (error) { console.error('Delete operation failed:', error); throw error; } } async sync() { if (!this.cloudCoordinator) { this.updateStatus(SyncStatus.OFFLINE); return { success: false, error: 'Cloud adapter not set', stats: { uploaded: 0, downloaded: 0, errors: 1 } }; } try { const pullResult = await this.pull(); const pushResult = await this.push(); return { success: pullResult.success && pushResult.success, error: pullResult.success ? pushResult.error : pullResult.error, syncedAt: Date.now(), stats: { uploaded: pushResult.stats?.uploaded || 0, downloaded: pullResult.stats?.downloaded || 0, errors: (pullResult.stats?.errors || 0) + (pushResult.stats?.errors || 0) } }; } catch (error) { this.updateStatus(SyncStatus.ERROR); console.error('Sync failed:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error', stats: { uploaded: 0, downloaded: 0, errors: 1 } }; } } async pull() { if (!this.cloudCoordinator) { this.updateStatus(SyncStatus.OFFLINE); return { success: false, error: 'Cloud adapter not set', stats: { uploaded: 0, downloaded: 0, errors: 1 } }; } const canPull = await this.checkPullAvailable(); if (!canPull) { this.updateStatus(SyncStatus.ERROR); return { success: false, error: 'Pull not available', stats: { uploaded: 0, downloaded: 0, errors: 1 } }; } try { this.updateStatus(SyncStatus.DOWNLOADING); //await this.localCoordinator.rebuildSyncView(); await this.cloudCoordinator.rebuildSyncView(); const localView = await this.localCoordinator.getCurrentView(); const cloudView = await this.cloudCoordinator.getCurrentView(); const { toDownload } = SyncView.diffViews(localView, cloudView); if (toDownload.length === 0) { this.updateStatus(SyncStatus.IDLE); return { success: true, syncedAt: Date.now(), stats: { uploaded: 0, downloaded: 0, errors: 0 } }; } const changeSet = await this.cloudCoordinator.extractChanges(toDownload); let downloadedCount = 0; for (const itemChanges of changeSet.put.values()) { downloadedCount += itemChanges.length; } for (const itemChanges of changeSet.delete.values()) { downloadedCount += itemChanges.length; } const latestVersion = Math.max(...toDownload.map(item => item._ver)); if (latestVersion && this.options.onVersionUpdate) { this.options.onVersionUpdate(latestVersion); } await this.localCoordinator.applyChanges(changeSet); this.options.onChangePulled?.(changeSet); this.updateStatus(SyncStatus.IDLE); return { success: true, syncedAt: Date.now(), stats: { uploaded: 0, downloaded: downloadedCount, errors: 0 } }; } catch (error) { this.updateStatus(SyncStatus.ERROR); console.error('[SyncEngine] Pull failed:', error); return { success: false, error: error instanceof Error ? error.message : 'Pull failed', stats: { uploaded: 0, downloaded: 0, errors: 1 } }; } } async push() { if (!this.cloudCoordinator) { this.updateStatus(SyncStatus.OFFLINE); return { success: false, error: 'Cloud adapter not set', stats: { uploaded: 0, downloaded: 0, errors: 1 } }; } const canPush = await this.checkPushAvailable(); if (!canPush) { this.updateStatus(SyncStatus.ERROR); return { success: false, error: 'Push not available', stats: { uploaded: 0, downloaded: 0, errors: 1 } }; } try { this.updateStatus(SyncStatus.UPLOADING); //await this.localCoordinator.rebuildSyncView(); // await this.cloudCoordinator.rebuildSyncView(); const localView = await this.localCoordinator.getCurrentView(); const cloudView = await this.cloudCoordinator.getCurrentView(); const { toUpload } = SyncView.diffViews(localView, cloudView); if (toUpload.length === 0) { this.updateStatus(SyncStatus.IDLE); return { success: true, syncedAt: Date.now(), stats: { uploaded: 0, downloaded: 0, errors: 0 } }; } const changeSet = await this.localCoordinator.extractChanges(toUpload); let uploadedCount = 0; for (const itemChanges of changeSet.put.values()) { uploadedCount += itemChanges.length; } for (const itemChanges of changeSet.delete.values()) { uploadedCount += itemChanges.length; } await this.cloudCoordinator.applyChanges(changeSet); const latestVersion = Math.max(...toUpload.map(item => item._ver)); if (latestVersion && this.options.onVersionUpdate) { this.options.onVersionUpdate(latestVersion); } this.options.onChangePushed?.(changeSet); this.updateStatus(SyncStatus.IDLE); return { success: true, syncedAt: Date.now(), stats: { uploaded: uploadedCount, downloaded: 0, errors: 0 } }; } catch (error) { this.updateStatus(SyncStatus.ERROR); console.error('[SyncEngine] Push failed:', error); return { success: false, error: error instanceof Error ? error.message : 'Push failed', stats: { uploaded: 0, downloaded: 0, errors: 1 } }; } } async query(storeName, options) { await this.ensureInitialized(); try { const result = await this.localCoordinator.query(storeName, options); return result; } catch (error) { console.error(`Query failed for store ${storeName}:`, error); throw error; } } enableAutoSync(pullInterval) { if (this.pullTimer) { clearInterval(this.pullTimer); } const currentAutoSync = this.options.autoSync || {}; this.options.autoSync = { ...currentAutoSync, enabled: true, pullInterval: pullInterval || currentAutoSync.pullInterval || 30000000 }; this.options.autoSync.pullInterval; this.pullTimer = setInterval(() => { this.executePullTask(); }, this.options.autoSync.pullInterval); } disableAutoSync() { if (this.pullTimer) { clearInterval(this.pullTimer); this.pullTimer = undefined; } if (this.pushDebounceTimer) { clearTimeout(this.pushDebounceTimer); this.pushDebounceTimer = undefined; } const currentAutoSync = this.options.autoSync || {}; this.options.autoSync = { ...currentAutoSync, enabled: false }; } async executePullTask() { if (this.syncStatus !== SyncStatus.IDLE) { return; } try { await this.pull(); } catch (error) { console.error('[SyncEngine] Pull task failed:', error); } } // callback handleDataChange() { if (!this.options.autoSync?.enabled) { return; } if (!this.cloudCoordinator) { return; } if (this.pushDebounceTimer) { clearTimeout(this.pushDebounceTimer); } this.pushDebounceTimer = setTimeout(async () => { try { if (this.canTriggerSync()) { await this.push(); } else { console.warn('[SyncEngine] Sync skipped caused by checking process'); } } catch (error) { console.error('[SyncEngine] Scheduled push failed:', error); } }, this.options.autoSync?.pushDebounce || 10000); } canTriggerSync() { const isAutoSyncEnabled = this.options.autoSync?.enabled === true; const canSync = Boolean(isAutoSyncEnabled && this.cloudCoordinator !== undefined && ![SyncStatus.ERROR, SyncStatus.UPLOADING, SyncStatus.DOWNLOADING].includes(this.syncStatus)); return canSync; } updateSyncOptions(options) { this.options = this.mergeDefaultOptions({ ...this.options, ...options }); if (options.autoSync) { if (options.autoSync.enabled) { this.enableAutoSync(options.autoSync.pullInterval); } else { this.disableAutoSync(); } } return this.options; } async clearCloudStores(stores) { if (!this.cloudCoordinator) { throw new Error('Cloud adapter not set'); } try { this.updateStatus(SyncStatus.OPERATING); const cloudAdapter = await this.cloudCoordinator.getAdapter(); const availableStores = await cloudAdapter.getStores(); const storesToClear = Array.isArray(stores) ? stores : [stores]; const invalidStores = storesToClear.filter(store => !availableStores.includes(store)); if (invalidStores.length > 0) { throw new Error(`Invalid stores: ${invalidStores.join(', ')}`); } const clearPromises = storesToClear.map(async (store) => { try { const result = await cloudAdapter.clearStore(store); if (!result) { console.warn(`Failed to clear store: ${store}`); } return { store, success: result }; } catch (error) { console.error(`Error clearing store ${store}:`, error); return { store, success: false }; } }); const results = await Promise.all(clearPromises); const failures = results.filter(r => !r.success); if (failures.length > 0) { throw new Error(`Failed to clear stores: ${failures.map(f => f.store).join(', ')}`); } this.updateStatus(SyncStatus.IDLE); } catch (error) { this.updateStatus(SyncStatus.ERROR); throw error; } } async clearLocalStores(stores) { try { this.updateStatus(SyncStatus.OPERATING); const localAdapter = await this.localCoordinator.getAdapter(); const availableStores = await localAdapter.getStores(); const storesToClear = Array.isArray(stores) ? stores : [stores]; const invalidStores = storesToClear.filter(store => !availableStores.includes(store)); if (invalidStores.length > 0) { throw new Error(`Invalid stores: ${invalidStores.join(', ')}`); } const clearPromises = storesToClear.map(async (store) => { try { const result = await localAdapter.clearStore(store); if (!result) { console.warn(`Failed to clear store: ${store}`); } return { store, success: result }; } catch (error) { console.error(`Error clearing store ${store}:`, error); return { store, success: false }; } }); const results = await Promise.all(clearPromises); const failures = results.filter(r => !r.success); if (failures.length > 0) { throw new Error(`Failed to clear stores: ${failures.map(f => f.store).join(', ')}`); } await this.localCoordinator.initSync(); this.updateStatus(SyncStatus.IDLE); } catch (error) { this.updateStatus(SyncStatus.ERROR); throw error; } } // get local coordinator getlocalCoordinator() { return this.localCoordinator; } getCloudCoordinator() { return this.cloudCoordinator; } // get local adapter getlocalAdapter() { return this.localCoordinator.adapter; } getCloudAdapter() { return this.cloudCoordinator?.adapter; } // dispose dispose() { this.disableAutoSync(); this.syncStatus = SyncStatus.OFFLINE; this.disconnectCloud(); this.isInitialized = false; } // disconnect cloud adapter disconnectCloud() { this.updateStatus(SyncStatus.OFFLINE); this.cloudCoordinator = undefined; } async checkPullAvailable() { if (this.options.onPullAvailableCheck) { try { const result = await Promise.resolve(this.options.onPullAvailableCheck()); if (!result) { } return result; } catch (error) { console.error('[SyncEngine] Pull availability check failed:', error); return false; } } return true; } async checkPushAvailable() { if (this.options.onPushAvailableCheck) { try { const result = await Promise.resolve(this.options.onPushAvailableCheck()); return result; } catch (error) { console.error('[SyncEngine] Push availability check failed:', error); return false; } } return true; } // private methods updateStatus(status) { this.syncStatus = status; if (this.options.onStatusUpdate) { try { this.options.onStatusUpdate(status); } catch (error) { console.error('Status update callback error:', error); } } } }