UNPKG

delta-sync

Version:

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

251 lines (250 loc) 8.64 kB
// core/LocalCoordinator.ts import { SyncView, } from './types'; export class Coordinator { constructor(adapter) { this.SYNC_VIEW_STORE = 'syncView'; this.SYNC_VIEW_KEY = 'current_view'; this.initialized = false; this.adapter = adapter; this.syncView = new SyncView(); } async initSync() { if (this.initialized) return; try { const result = await this.adapter.readBulk(this.SYNC_VIEW_STORE, [this.SYNC_VIEW_KEY]); if (result.length > 0 && result[0]?.items) { this.syncView = SyncView.deserialize(JSON.stringify(result[0].items)); } else { await this.rebuildSyncView(); } this.initialized = true; } catch (error) { console.error('Failed to initialize sync engine:', error); throw error; } } async ensureInitialized() { if (!this.initialized) { await this.initSync(); } } async disposeSync() { try { await this.persistView(); this.syncView.clear(); this.initialized = false; } catch (error) { console.error('Failed to dispose sync engine:', error); throw error; } } async getCurrentView() { await this.ensureInitialized(); const result = await this.adapter.readBulk(this.SYNC_VIEW_STORE, [this.SYNC_VIEW_KEY]); if (result.length > 0 && result[0]?.items) { return SyncView.deserialize(JSON.stringify(result[0].items)); } return new SyncView(); // Return an empty view } async readBulk(storeName, ids) { try { return await this.adapter.readBulk(storeName, ids); } catch (error) { console.error(`Failed to read bulk data from store ${storeName}:`, error); throw error; } } // Data put to the adapter will be tagged with a new version number. async putBulk(storeName, items) { await this.ensureInitialized(); try { const newVersion = Date.now(); const itemsWithVersion = items.map(item => ({ ...item, _ver: newVersion })); const results = await this.adapter.putBulk(storeName, itemsWithVersion); for (const item of results) { this.syncView.upsert({ id: item.id, store: storeName, _ver: newVersion, }); } await this.persistView(); return results; } catch (error) { console.error(`Failed to put bulk data to store ${storeName}:`, error); throw error; } } async extractChanges(items) { const deleteMap = new Map(); const putMap = new Map(); const storeGroups = new Map(); for (const item of items) { if (!storeGroups.has(item.store)) { storeGroups.set(item.store, []); } storeGroups.get(item.store).push(item); } for (const [store, storeItems] of storeGroups) { const deletedItems = storeItems.filter(item => item.deleted); const updateItems = storeItems.filter(item => !item.deleted); if (deletedItems.length > 0) { deleteMap.set(store, deletedItems.map(item => ({ id: item.id, _ver: item._ver }))); } if (updateItems.length > 0) { const data = await this.adapter.readBulk(store, updateItems.map(item => item.id)); putMap.set(store, data.map(item => ({ id: item.id, data: item, _ver: updateItems.find(i => i.id === item.id)?._ver || Date.now() }))); } } return { delete: deleteMap, put: putMap }; } async applyChanges(changeSet) { await this.ensureInitialized(); try { for (const [store, changes] of changeSet.delete) { await this.adapter.deleteBulk(store, changes.map(c => c.id)); changes.forEach(change => { this.syncView.upsert({ id: change.id, store, _ver: change._ver, deleted: true }); }); } for (const [store, changes] of changeSet.put) { await this.adapter.putBulk(store, changes.map(c => c.data)); changes.forEach(change => { this.syncView.upsert({ id: change.id, store, _ver: change._ver }); }); } await this.persistView(); } catch (error) { console.error('Failed to apply changes:', error); throw error; } } async deleteBulk(storeName, ids, _ver) { await this.ensureInitialized(); try { await this.adapter.deleteBulk(storeName, ids); const currentVersion = _ver || Date.now(); for (const id of ids) { this.syncView.upsert({ id, store: storeName, _ver: currentVersion, deleted: true }); } await this.persistView(); } catch (error) { console.error('Failed to delete data:', error); throw error; } } async getAdapter() { await this.ensureInitialized(); return this.adapter; } async rebuildSyncView() { try { this.syncView.clear(); const stores = await this.adapter.getStores(); const allStores = stores.filter(store => store !== this.SYNC_VIEW_STORE); for (const store of allStores) { let offset = 0; const limit = 100; while (true) { const { items, hasMore } = await this.adapter.readStore(store, limit, offset); for (const item of items) { if (item?.id) { this.syncView.upsert({ id: item.id, store: store, _ver: Date.now(), }); } } if (!hasMore) break; offset += limit; } } await this.persistView(); } catch (error) { console.error('Failed to rebuild sync view:', error); throw error; } } async refreshView() { const result = await this.adapter.readBulk(this.SYNC_VIEW_STORE, [this.SYNC_VIEW_KEY]); if (result.length > 0 && result[0]?.items) { this.syncView = SyncView.deserialize(JSON.stringify(result[0].items)); } else { this.syncView = new SyncView(); } } async query(storeName, options = {}) { await this.ensureInitialized(); const { since = 0, offset = 0, limit = 100 } = options; try { const result = await this.adapter.readStore(storeName, limit, offset); if (since > 0) { const filteredItems = result.items.filter(item => { const viewItem = this.syncView.get(storeName, item.id); return viewItem && viewItem._ver > since; }); return { items: filteredItems, hasMore: result.hasMore }; } return result; } catch (error) { console.error(`Query failed for store ${storeName}:`, error); throw error; } } async persistView() { try { const serializedView = { id: this.SYNC_VIEW_KEY, items: JSON.parse(this.syncView.serialize()) }; await this.adapter.putBulk(this.SYNC_VIEW_STORE, [serializedView]); } catch (error) { console.error('Failed to persist view:', error); throw error; } } }