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
JavaScript
// 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;
}
}
}