delta-sync
Version:
A lightweight framework for bi-directional database synchronization with automatic version tracking and conflict resolution.
403 lines (402 loc) • 16.8 kB
JavaScript
// adapters/indexeddb.ts
// 基于indexeddb的适配器
export class IndexedDBAdapter {
constructor(options = {}) {
this.db = null;
this.initPromise = null;
this.stores = new Set();
this.dbName = options.dbName || 'deltaSyncDB';
this.fileStoreName = options.fileStoreName || '_files'; // 默认文件存储名称
}
async isAvailable() {
return !!window.indexedDB;
}
async initSync() {
if (this.db)
return;
if (this.initPromise)
return this.initPromise;
this.initPromise = new Promise((resolve, reject) => {
try {
// 不指定版本打开数据库,让浏览器自动处理版本
const request = indexedDB.open(this.dbName);
request.onerror = () => {
reject(new Error(`Failed to open IndexedDB database: ${request.error?.message || 'Unknown error'}`));
};
request.onsuccess = () => {
this.db = request.result;
// 添加版本变更监听器
this.db.onversionchange = () => {
console.warn('Database version changed. Closing connection.');
this.db?.close();
this.db = null;
this.initPromise = null;
};
// 初始化时收集所有已有的仓库
for (let i = 0; i < this.db.objectStoreNames.length; i++) {
this.stores.add(this.db.objectStoreNames[i]);
}
resolve();
};
}
catch (error) {
reject(new Error(`Failed to initialize IndexedDB: ${error instanceof Error ? error.message : String(error)}`));
}
});
return this.initPromise;
}
async ensureStore(storeName) {
if (!this.db) {
await this.initSync();
}
if (this.stores.has(storeName)) {
return;
}
if (!this.db.objectStoreNames.contains(storeName)) {
const currentVersion = this.db.version;
this.db.close();
this.db = null;
this.initPromise = null;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, currentVersion + 1);
request.onerror = () => {
reject(new Error(`Failed to upgrade database for store: ${storeName}`));
};
request.onupgradeneeded = (event) => {
const db = request.result;
try {
let objectStore;
if (storeName === this.fileStoreName) {
// 创建文件存储,使用_sync_id作为键
objectStore = db.createObjectStore(storeName, { keyPath: '_sync_id' });
objectStore.createIndex('_created_at', '_created_at', { unique: false });
objectStore.createIndex('_updated_at', '_updated_at', { unique: false });
// 可以根据文件元数据添加其他索引
objectStore.createIndex('metadata.mimeType', 'metadata.mimeType', { unique: false });
}
else {
// 标准数据存储
objectStore = db.createObjectStore(storeName, { keyPath: '_sync_id' });
objectStore.createIndex('_ver', '_ver', { unique: false });
objectStore.createIndex('_sync_status', '_sync_status', { unique: false });
objectStore.createIndex('_created_at', '_created_at', { unique: false });
}
}
catch (error) {
console.error('Error creating store:', error);
}
};
request.onsuccess = () => {
this.db = request.result;
this.stores.add(storeName);
this.db.onversionchange = () => {
this.db?.close();
this.db = null;
this.initPromise = null;
};
resolve();
};
});
}
else {
this.stores.add(storeName);
}
}
async read(storeName, options = {}) {
await this.ensureStore(storeName);
const limit = options.limit || Number.MAX_SAFE_INTEGER;
const offset = options.offset || 0;
const since = options.since || 0;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const items = [];
let skipped = 0;
let processed = 0;
let hasMore = false;
try {
let request;
if (since > 0) {
const verIndex = store.index('_ver');
const range = IDBKeyRange.lowerBound(since, true);
request = verIndex.openCursor(range);
}
else {
request = store.openCursor();
}
request.onerror = () => {
reject(new Error(`Failed to read data from ${storeName}`));
};
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (skipped < offset) {
skipped++;
cursor.continue();
}
else if (processed < limit) {
items.push(cursor.value);
processed++;
cursor.continue();
}
else {
hasMore = true;
resolve({ items, hasMore });
}
}
else {
resolve({ items, hasMore });
}
};
}
catch (error) {
// 索引查询失败时回退到标准方法
console.warn(`Index query failed, fallback to standard method: ${error}`);
const request = store.openCursor();
request.onerror = () => {
reject(new Error(`Failed to read data from ${storeName}`));
};
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const item = cursor.value;
if (!since || (item._ver && item._ver > since)) {
if (skipped < offset) {
skipped++;
}
else if (processed < limit) {
items.push(item);
processed++;
}
else {
hasMore = true;
resolve({ items, hasMore });
return;
}
}
cursor.continue();
}
else {
resolve({ items, hasMore });
}
};
}
});
}
async readBulk(storeName, ids) {
await this.ensureStore(storeName);
const transaction = this.db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const promises = ids.map(id => new Promise((resolve) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
console.error(`Failed to read ${id} from ${storeName}`);
resolve(undefined);
};
}));
const results = await Promise.all(promises);
return results.filter(result => result !== undefined);
}
async putBulk(storeName, items) {
if (!items.length)
return [];
await this.ensureStore(storeName);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const itemsWithMeta = items.map(item => {
if (!item._ver) {
return {
...item,
_ver: Date.now(),
_store: storeName
};
}
return item;
});
itemsWithMeta.forEach(item => {
store.put(item);
});
transaction.oncomplete = () => resolve(itemsWithMeta);
transaction.onerror = () => reject(new Error(`Failed to put items in ${storeName}`));
});
}
async deleteBulk(storeName, ids) {
if (!ids.length)
return;
await this.ensureStore(storeName);
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
ids.forEach(id => {
store.delete(id);
});
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(new Error(`Failed to delete items from ${storeName}`));
});
}
async clearStore(storeName) {
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
return;
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(new Error(`Failed to clear store ${storeName}`));
});
}
async readFile(fileUrl) {
await this.ensureFileStore();
try {
// 提取文件的ID部分
const fileId = this.extractFileId(fileUrl);
const transaction = this.db.transaction(this.fileStoreName, 'readonly');
const store = transaction.objectStore(this.fileStoreName);
return new Promise((resolve, reject) => {
const request = store.get(fileId);
request.onerror = () => {
reject(new Error(`Failed to read file: ${fileUrl}`));
};
request.onsuccess = () => {
const fileData = request.result;
if (!fileData) {
reject(new Error(`File not found: ${fileUrl}`));
return;
}
resolve({
content: fileData.content,
metadata: fileData.metadata
});
};
});
}
catch (error) {
throw new Error(`Error reading file ${fileUrl}: ${error instanceof Error ? error.message : String(error)}`);
}
}
async readFileMeta(fileUrl) {
await this.ensureFileStore();
try {
const fileId = this.extractFileId(fileUrl);
const transaction = this.db.transaction(this.fileStoreName, 'readonly');
const store = transaction.objectStore(this.fileStoreName);
return new Promise((resolve, reject) => {
const request = store.get(fileId);
request.onerror = () => {
reject(new Error(`Failed to read file metadata: ${fileUrl}`));
};
request.onsuccess = () => {
const fileData = request.result;
if (!fileData) {
reject(new Error(`File metadata not found: ${fileUrl}`));
return;
}
resolve({
metadata: fileData.metadata
});
};
});
}
catch (error) {
throw new Error(`Error reading file metadata ${fileUrl}: ${error instanceof Error ? error.message : String(error)}`);
}
}
async saveFile(fileData) {
await this.ensureFileStore();
try {
const now = Date.now();
const fileId = fileData.metadata.url ?
this.extractFileId(fileData.metadata.url) :
`file_${now}_${Math.random().toString(36).substring(2, 10)}`;
// 处理内容,如果是字符串转为Blob
const content = typeof fileData.content === 'string'
? new Blob([fileData.content], { type: fileData.metadata.mimeType || 'text/plain' })
: fileData.content;
// 构建完整的附件元数据
const attachment = {
id: fileId,
url: fileData.metadata.url || `idb://${this.dbName}/${this.fileStoreName}/${fileId}`,
mimeType: fileData.metadata.mimeType,
size: fileData.metadata.size || (content instanceof Blob ? content.size : content.byteLength),
createdAt: now,
updatedAt: now,
metadata: fileData.metadata.metadata || {}
};
const transaction = this.db.transaction(this.fileStoreName, 'readwrite');
const store = transaction.objectStore(this.fileStoreName);
return new Promise((resolve, reject) => {
const request = store.put({
_sync_id: fileId,
content: content,
metadata: attachment,
_created_at: now,
_updated_at: now
});
request.onerror = () => {
reject(new Error(`Failed to save file: ${attachment.url}`));
};
request.onsuccess = () => {
resolve(attachment);
};
transaction.onerror = () => {
reject(new Error(`Transaction failed when saving file: ${attachment.url}`));
};
});
}
catch (error) {
throw new Error(`Error saving file: ${error instanceof Error ? error.message : String(error)}`);
}
}
async deleteFile(fileId) {
await this.ensureFileStore();
try {
// 如果传入的是URL,提取文件ID
const actualFileId = fileId.includes('/') ? this.extractFileId(fileId) : fileId;
const transaction = this.db.transaction(this.fileStoreName, 'readwrite');
const store = transaction.objectStore(this.fileStoreName);
return new Promise((resolve, reject) => {
const request = store.delete(actualFileId);
request.onerror = () => {
reject(new Error(`Failed to delete file: ${fileId}`));
};
request.onsuccess = () => {
resolve();
};
});
}
catch (error) {
throw new Error(`Error deleting file: ${error instanceof Error ? error.message : String(error)}`);
}
}
async ensureFileStore() {
await this.ensureStore(this.fileStoreName);
}
extractFileId(fileUrl) {
if (fileUrl.startsWith('idb://')) {
const parts = fileUrl.split('/');
return parts[parts.length - 1];
}
return fileUrl;
}
async close() {
if (this.db) {
this.db.close();
this.db = null;
this.initPromise = null;
}
}
async deleteDatabase() {
await this.close();
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(this.dbName);
request.onsuccess = () => {
this.stores.clear();
resolve();
};
request.onerror = () => reject(new Error(`Failed to delete database ${this.dbName}`));
});
}
}