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