UNPKG

delta-sync

Version:

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

275 lines (274 loc) 9.66 kB
// core/LocalCoordinator.ts import { SyncView, } from './types'; export class Coordinator { constructor(adapter) { this.TOMBSTONE_STORE = 'tombStones'; this.TOMBSTONE_RETENTION = 180 * 24 * 60 * 60 * 1000; // 180 days this.initialized = false; this.adapter = adapter; this.syncView = new SyncView(); } async initSync() { if (this.initialized) return; try { await this.rebuildSyncView(); await this.clearOldTombstones(); 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 { this.syncView.clear(); this.initialized = false; } catch (error) { console.error('Failed to dispose sync engine:', error); throw error; } } async getCurrentView() { await this.ensureInitialized(); return this.syncView; } async readAll(storeName) { try { let allItems = []; let offset = 0; const limit = 100; while (true) { const { items, hasMore } = await this.adapter.readStore(storeName, limit, offset); allItems = allItems.concat(items); if (!hasMore) break; offset += limit; } return allItems; } catch (error) { console.error(`Failed to read all data from store ${storeName}:`, error); throw error; } } 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, }); } 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 deleteBulk(storeName, ids) { await this.ensureInitialized(); try { await this.adapter.deleteBulk(storeName, ids); const currentVersion = Date.now(); const tombstones = ids.map(id => ({ id, store: storeName, _ver: currentVersion, deleted: true })); await this.addTombstones(tombstones); this.syncView.upsertBatch(tombstones); } 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()) .filter(store => store !== this.TOMBSTONE_STORE); for (const store of stores) { const items = await this.readAll(store); const itemsToUpsert = items .filter(item => item && item.id) .map(item => ({ id: item.id, store: store, _ver: item._ver || Date.now(), })); this.syncView.upsertBatch(itemsToUpsert); } const tombstones = await this.readAll(this.TOMBSTONE_STORE); this.syncView.upsertBatch(tombstones); } catch (error) { console.error('Failed to rebuild sync view:', error); throw error; } } 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; } } // core methods for applying changes async applyChanges(changeSet) { await this.ensureInitialized(); try { for (const [store, changes] of changeSet.put) { await this.removeRelatedTombstones(store, changes.map(c => c.id)); await this.adapter.putBulk(store, changes.map(c => c.data)); const syncViewItems = changes.map(change => ({ id: change.id, store, _ver: change._ver, deleted: false })); this.syncView.upsertBatch(syncViewItems); } for (const [store, changes] of changeSet.delete) { await this.adapter.deleteBulk(store, changes.map(c => c.id)); const syncViewItems = changes.map(change => ({ id: change.id, store, _ver: change._ver, deleted: true })); await this.addTombstones(syncViewItems); this.syncView.upsertBatch(syncViewItems); } } catch (error) { console.error('Failed to apply changes:', error); throw error; } } async count(storeName, includeDeleted = false) { await this.ensureInitialized(); try { return this.syncView.countByStore(storeName, includeDeleted); } catch (error) { console.error(`Failed to count items in store ${storeName}:`, error); throw error; } } async removeRelatedTombstones(store, ids) { const tombstoneIds = ids.filter(id => { const item = this.syncView.get(store, id); return item && item.deleted; }); if (tombstoneIds.length > 0) { try { await this.adapter.deleteBulk(this.TOMBSTONE_STORE, tombstoneIds); console.log(`Removed ${tombstoneIds.length} tombstones for resurrected items`); } catch (error) { console.error(`Failed to remove tombstones for ${store}:`, error); } } } async clearOldTombstones() { try { const { items } = await this.adapter.readStore(this.TOMBSTONE_STORE); const now = Date.now(); const expiredTombstones = items.filter(item => item._ver < (now - this.TOMBSTONE_RETENTION)); if (expiredTombstones.length > 0) { const expiredIds = expiredTombstones.map(item => item.id); await this.adapter.deleteBulk(this.TOMBSTONE_STORE, expiredIds); expiredTombstones.forEach(item => { this.syncView.delete(item.store, item.id); }); } } catch (error) { console.error('Failed to clear old tombstones:', error); } } async addTombstones(items) { await this.adapter.putBulk(this.TOMBSTONE_STORE, items); } }