delta-sync
Version:
A lightweight framework for bi-directional database synchronization with automatic version tracking and conflict resolution.
481 lines (480 loc) • 17.4 kB
JavaScript
// 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);
}
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.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 {
}
}
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;
}
// 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);
}
}
}
}