UNPKG

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