UNPKG

@omnistreamai/data-core

Version:

647 lines (642 loc) 18.9 kB
//#region src/types.ts /** * Adapter type: local or remote */ let AdapterType = /* @__PURE__ */ function(AdapterType$1) { AdapterType$1["Local"] = "local"; AdapterType$1["Remote"] = "remote"; return AdapterType$1; }({}); /** * Authentication type */ let AuthType = /* @__PURE__ */ function(AuthType$1) { AuthType$1["Token"] = "token"; AuthType$1["Basic"] = "basic"; AuthType$1["Bearer"] = "bearer"; return AuthType$1; }({}); let SyncOperation = /* @__PURE__ */ function(SyncOperation$1) { SyncOperation$1["Create"] = "create"; SyncOperation$1["Update"] = "update"; SyncOperation$1["Delete"] = "delete"; return SyncOperation$1; }({}); let SyncStatus = /* @__PURE__ */ function(SyncStatus$1) { SyncStatus$1["Pending"] = "pending"; SyncStatus$1["Syncing"] = "syncing"; SyncStatus$1["Success"] = "success"; SyncStatus$1["Failed"] = "failed"; return SyncStatus$1; }({}); const DEFAULT_RETRY_POLICY = { maxRetries: 3, baseDelayMs: 5e3, maxDelayMs: 3e5, backoff: "exponential", jitter: true, retryableStatuses: [ 408, 429, 500, 502, 503, 504 ], retryableErrors: [ "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "timeout", "network", "fetch failed" ] }; //#endregion //#region src/pending-queue.ts var PendingQueue = class { QUEUE_STORE = "__sync_pending_queue"; policy; localAdapter; initialized = false; constructor(localAdapter, config = {}) { this.localAdapter = localAdapter; this.policy = this.mergePolicy(config.defaultPolicy); } mergePolicy(userPolicy) { const policy = { ...DEFAULT_RETRY_POLICY, ...userPolicy }; return { maxRetries: policy.maxRetries, baseDelayMs: policy.baseDelayMs, maxDelayMs: policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs, backoff: policy.backoff, jitter: policy.jitter ?? true, retryableStatuses: policy.retryableStatuses ?? [ 408, 429, 500, 502, 503, 504 ], retryableErrors: policy.retryableErrors ?? [ "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "timeout", "network", "fetch failed" ] }; } async ensureInitialized() { if (this.initialized) return; await this.localAdapter.initStore(this.QUEUE_STORE, [], "id"); this.initialized = true; } isRetryable(error, statusCode) { if (statusCode) { if (this.policy.retryableStatuses?.includes(statusCode)) return true; if (statusCode >= 400 && statusCode < 500 && ![408, 429].includes(statusCode)) return false; } const errorMessage = error instanceof Error ? error.message : String(error); return this.policy.retryableErrors?.some((e) => errorMessage.toLowerCase().includes(e.toLowerCase())) ?? false; } extractStatusCode(error) { const match = (error instanceof Error ? error.message : String(error)).match(/(?:http[:\s]*)?(\d{3})/i); if (!match) return void 0; const statusCode = match[1]; return statusCode ? parseInt(statusCode, 10) : void 0; } async add(item) { await this.ensureInitialized(); const existing = await this.findExisting(item.storeName, item.recordId, item.operation); if (existing) { existing.attempts += 1; existing.lastAttempt = Date.now(); existing.lastError = item.error; existing.status = SyncStatus.Pending; await this.localAdapter.update(this.QUEUE_STORE, existing.id, { attempts: existing.attempts, lastAttempt: existing.lastAttempt, lastError: existing.lastError, status: SyncStatus.Pending }, "id"); return { item: existing, isNew: false }; } const newItem = { ...item, id: crypto.randomUUID(), attempts: 1, status: SyncStatus.Pending, createdAt: Date.now(), lastAttempt: Date.now(), maxRetries: this.policy.maxRetries }; await this.localAdapter.add(this.QUEUE_STORE, newItem, "id"); return { item: newItem, isNew: true }; } async findExisting(storeName, recordId, operation) { return (await this.localAdapter.getList(this.QUEUE_STORE, { where: { storeName, recordId, operation } }, "id")).find((item) => item.status === SyncStatus.Pending) ?? null; } async getPendingItems(storeName) { await this.ensureInitialized(); return (await this.localAdapter.getList(this.QUEUE_STORE, { where: { storeName } }, "id")).filter((item) => item.status === SyncStatus.Pending); } async getRetryableItems(storeName) { const items = await this.getPendingItems(storeName); const now = Date.now(); return items.filter((item) => { return now >= this.calculateNextRetryTime(item.attempts); }); } calculateNextRetryTime(attempt) { if (attempt <= 1) return Date.now(); let delay; switch (this.policy.backoff) { case "fixed": delay = this.policy.baseDelayMs; break; case "linear": delay = this.policy.baseDelayMs * (attempt - 1); break; case "exponential": default: delay = this.policy.baseDelayMs * Math.pow(2, attempt - 2); break; } if (this.policy.jitter) delay += Math.random() * this.policy.baseDelayMs * .5; if (this.policy.maxDelayMs) delay = Math.min(delay, this.policy.maxDelayMs); return Date.now() + delay; } async markSyncing(id) { await this.localAdapter.update(this.QUEUE_STORE, id, { status: SyncStatus.Syncing, lastAttempt: Date.now() }, "id"); } async markSuccess(id) { await this.localAdapter.update(this.QUEUE_STORE, id, { status: SyncStatus.Success, completedAt: Date.now() }, "id"); } async markFailed(id, error) { await this.localAdapter.update(this.QUEUE_STORE, id, { status: SyncStatus.Failed, lastError: error, completedAt: Date.now() }, "id"); } async remove(id) { await this.localAdapter.delete(this.QUEUE_STORE, id, "id"); } async getPendingCount(storeName) { return (await this.getPendingItems(storeName)).length; } async clear(storeName) { const items = await this.getPendingItems(storeName); await Promise.all(items.map((item) => this.remove(item.id))); } updatePolicy(config) { if (config.defaultPolicy) this.policy = this.mergePolicy(config.defaultPolicy); } getPolicy() { return this.policy; } }; //#endregion //#region src/store.ts /** * Store class */ var Store = class { localAdapter; remoteAdapter; storeName; idKey; indexes; maxLocalEntries; sortByKey; onChange; notThrowLocalErrorProps; notThrowLocalError; initialized = false; /** Pending queue for failed sync operations */ pendingQueue; /** Sync configuration */ syncConfig; /** Sync callbacks */ syncCallbacks; constructor(storeName, options) { this.storeName = storeName; this.localAdapter = options.localAdapter; this.remoteAdapter = options.remoteAdapter; this.idKey = options.idKey; this.indexes = options.indexes; this.maxLocalEntries = options.maxLocalEntries; this.sortByKey = options.sortByKey; this.onChange = options.onChange; this.notThrowLocalErrorProps = options.notThrowLocalError; this.notThrowLocalError = options.notThrowLocalError && !!this.remoteAdapter; this.syncConfig = options.syncConfig; this.syncCallbacks = options.syncCallbacks; if (this.syncConfig && this.localAdapter) this.pendingQueue = new PendingQueue(this.localAdapter, this.syncConfig); } /** * Initialize Store */ async init() { if (this.initialized) return; const promises = []; if (this.localAdapter) promises.push(this.executeLocal(() => this.localAdapter.initStore(this.storeName, this.indexes, this.idKey))); if (this.remoteAdapter) promises.push(this.executeRemote(() => this.remoteAdapter.initStore(this.storeName, this.indexes, this.idKey))); await Promise.all(promises); this.initialized = true; } async ensureInitialized() { if (!this.initialized) await this.init(); } triggerChange() { if (this.onChange) this.onChange(); } async executeLocal(operation) { if (!this.localAdapter) return null; try { return await operation(); } catch (error) { if (this.notThrowLocalError) { console.warn("Local adapter error:", error); return null; } throw error; } } async executeRemote(operation) { return await operation(); } /** * Enforce maximum local storage entries limit */ async enforceMaxLocalEntries() { if (!this.localAdapter || !this.maxLocalEntries) return; try { let currentEntries = await this.localAdapter.getList(this.storeName, {}, this.idKey); if (currentEntries.length > this.maxLocalEntries) { if (this.sortByKey) currentEntries = this.sortEntries(currentEntries, this.sortByKey); const entriesToDelete = currentEntries.length - this.maxLocalEntries; for (let i = 0; i < entriesToDelete; i++) { const entryToDelete = currentEntries[i]; if (entryToDelete) { const id = String(entryToDelete[this.idKey]); await this.executeLocal(() => this.localAdapter.delete(this.storeName, id, this.idKey)); } } } } catch (error) { if (this.notThrowLocalError) console.warn("Failed to enforce max local entries:", error); else throw error; } } /** * Sort entries by specified field * @param entries Array of entries to sort * @param key Sorting field name * @returns Sorted array of entries */ sortEntries(entries, key) { return [...entries].sort((a, b) => { const aValue = a[key]; const bValue = b[key]; if (aValue == null && bValue == null) return 0; if (aValue == null) return 1; if (bValue == null) return -1; if (typeof aValue === "number" && typeof bValue === "number") return aValue - bValue; if (typeof aValue === "string" && typeof bValue === "string") return aValue.localeCompare(bValue); if (typeof aValue === "string" && typeof bValue === "string") { const aDate = new Date(aValue); const bDate = new Date(bValue); if (!isNaN(aDate.getTime()) && !isNaN(bDate.getTime())) return aDate.getTime() - bDate.getTime(); } return String(aValue).localeCompare(String(bValue)); }); } /** * Handle remote error - add to pending queue if retryable */ async handleRemoteError(error, operation, data) { if (!this.pendingQueue) return; const statusCode = this.pendingQueue.extractStatusCode(error); if (this.pendingQueue.isRetryable(error, statusCode)) { const { item } = await this.pendingQueue.add({ storeName: this.storeName, operation, data, idKey: this.idKey, recordId: String(data[this.idKey]), error: error instanceof Error ? error.message : String(error) }); if (this.syncCallbacks?.onSyncFailed) { const retryInfo = { item, error: error instanceof Error ? error : new Error(String(error)), isMaxRetriesExceeded: item.attempts >= item.maxRetries, attempt: item.attempts, nextRetryDelayMs: this.pendingQueue.calculateNextRetryTime(item.attempts) }; this.syncCallbacks.onSyncFailed(retryInfo); } } } /** /** * Add data */ async add(data) { await this.ensureInitialized(); if (this.localAdapter) { if (await this.executeLocal(() => this.localAdapter.add(this.storeName, data, this.idKey))) await this.enforceMaxLocalEntries(); } if (this.remoteAdapter) try { await this.remoteAdapter.add(this.storeName, data, this.idKey); } catch (error) { await this.handleRemoteError(error, SyncOperation.Create, data); } this.triggerChange(); return data; } /** * Update data */ async update(id, data) { await this.ensureInitialized(); let localResult = null; if (this.localAdapter) localResult = await this.executeLocal(() => this.localAdapter.update(this.storeName, id, data, this.idKey)); if (this.remoteAdapter) try { await this.remoteAdapter.update(this.storeName, id, data, this.idKey); } catch (error) { await this.handleRemoteError(error, SyncOperation.Update, { id, ...data }); } this.triggerChange(); return localResult; } /** * Delete data */ async delete(id) { await this.ensureInitialized(); if (this.localAdapter) await this.executeLocal(() => this.localAdapter.delete(this.storeName, id, this.idKey)); if (this.remoteAdapter) try { await this.remoteAdapter.delete(this.storeName, id, this.idKey); } catch (error) { await this.handleRemoteError(error, SyncOperation.Delete, { [this.idKey]: id }); } this.triggerChange(); } /** * Get data by ID */ async getData(id) { await this.ensureInitialized(); if (this.localAdapter) try { const localResult = await this.executeLocal(() => this.localAdapter.getData(this.storeName, id, this.idKey)); if (localResult) return localResult; } catch (error) { if (!this.notThrowLocalError) throw error; } if (this.remoteAdapter) return await this.remoteAdapter.getData(this.storeName, id, this.idKey); return null; } /** * Get list data */ async getList(options = {}) { await this.ensureInitialized(); const source = options.source; if (source === "local" && this.localAdapter) return await this.localAdapter.getList(this.storeName, options, this.idKey); if (source === "remote" && this.remoteAdapter) return (await this.remoteAdapter.getList(this.storeName, options, this.idKey)).data; if (this.remoteAdapter) try { const result = await this.remoteAdapter.getList(this.storeName, options, this.idKey); if (this.localAdapter && result.data.length > 0) { await Promise.all(result.data.map((item) => this.executeLocal(() => this.localAdapter.add(this.storeName, item, this.idKey)))); await this.enforceMaxLocalEntries(); } return result.data; } catch (error) { if (this.localAdapter) return await this.localAdapter.getList(this.storeName, options, this.idKey); throw error; } if (this.localAdapter) return await this.localAdapter.getList(this.storeName, options, this.idKey); return []; } /** * Get query object for list data */ getListQuery(options = {}) { const source = options.source; const queryKey = [ "store", this.storeName, source || "default", options ]; const queryFn = async () => { return this.getList(options); }; const getInitialData = async () => { await this.ensureInitialized(); if (this.localAdapter) try { return await this.localAdapter.getList(this.storeName, options, this.idKey); } catch (error) { return []; } return []; }; return { queryKey, queryFn, getInitialData }; } /** * Clear store */ async clear() { await this.ensureInitialized(); const promises = []; if (this.localAdapter) await this.executeLocal(() => this.localAdapter.clear(this.storeName)); if (this.remoteAdapter) promises.push(this.remoteAdapter.clear(this.storeName)); await Promise.all(promises); this.triggerChange(); } setLocalAdapter(adapter) { this.localAdapter = adapter; this.initialized = false; return this.init(); } setRemoteAdapter(adapter) { this.remoteAdapter = adapter; this.notThrowLocalError = this.notThrowLocalErrorProps && !!adapter; this.initialized = false; return this.init(); } /** * Get pending sync items count */ async getPendingCount() { if (!this.pendingQueue) return 0; return await this.pendingQueue.getPendingCount(this.storeName); } /** * Sync all pending items */ async syncPending() { if (!this.pendingQueue || !this.remoteAdapter) return { success: 0, failed: 0 }; const pendingItems = await this.pendingQueue.getRetryableItems(this.storeName); let success = 0; let failed = 0; for (const item of pendingItems) { await this.pendingQueue.markSyncing(item.id); try { switch (item.operation) { case SyncOperation.Create: await this.remoteAdapter.add(item.storeName, item.data, item.idKey); break; case SyncOperation.Update: await this.remoteAdapter.update(item.storeName, item.recordId, item.data, item.idKey); break; case SyncOperation.Delete: await this.remoteAdapter.delete(item.storeName, item.recordId, item.idKey); break; } await this.pendingQueue.markSuccess(item.id); success++; if (this.syncCallbacks?.onSyncSuccess) this.syncCallbacks.onSyncSuccess(item); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const policy = this.pendingQueue.getPolicy(); if (item.attempts >= policy.maxRetries) { await this.pendingQueue.markFailed(item.id, errorMessage); failed++; if (this.syncCallbacks?.onSyncFailed) this.syncCallbacks.onSyncFailed({ item, error: error instanceof Error ? error : new Error(errorMessage), isMaxRetriesExceeded: true, attempt: item.attempts }); } else { const { item: updatedItem } = await this.pendingQueue.add({ storeName: item.storeName, operation: item.operation, data: item.data, idKey: item.idKey, recordId: item.recordId, error: errorMessage }); if (this.syncCallbacks?.onSyncFailed) this.syncCallbacks.onSyncFailed({ item: updatedItem, error: error instanceof Error ? error : new Error(errorMessage), isMaxRetriesExceeded: false, attempt: updatedItem.attempts }); } } } return { success, failed }; } }; //#endregion //#region src/sync-manager.ts /** * Sync manager */ var SyncManager = class { localAdapter = null; remoteAdapter = null; notThrowLocalError; stores = /* @__PURE__ */ new Map(); constructor(options = {}) { this.localAdapter = options.localAdapter ?? null; this.remoteAdapter = options.remoteAdapter ?? null; this.notThrowLocalError = options.notThrowLocalError ?? false; } /** * Set local adapter */ async setLocalAdapter(adapter) { this.localAdapter = adapter; for (const [_, store] of this.stores) await store.setLocalAdapter(adapter); } /** * Set remote adapter */ async setRemoteAdapter(adapter) { this.remoteAdapter = adapter; for (const [_, store] of this.stores) await store.setRemoteAdapter(adapter); } /** * Create Store */ createStore(storeName, config = {}) { const existingStore = this.stores.get(storeName); if (existingStore) return existingStore; const store = new Store(storeName, { localAdapter: this.localAdapter, remoteAdapter: this.remoteAdapter, idKey: config.idKey ?? "id", indexes: config.indexes, maxLocalEntries: config.maxLocalEntries, onChange: config.onChange, notThrowLocalError: this.notThrowLocalError, syncConfig: config.syncConfig, syncCallbacks: config.syncCallbacks }); this.stores.set(storeName, store); return store; } initStore() { const promiseList = []; this.stores.forEach((store) => { promiseList.push(store.init()); }); return Promise.all(promiseList); } }; /** * Create sync manager */ function createSyncManager(options = {}) { return new SyncManager(options); } //#endregion exports.AdapterType = AdapterType; exports.AuthType = AuthType; exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY; exports.Store = Store; exports.SyncManager = SyncManager; exports.SyncOperation = SyncOperation; exports.SyncStatus = SyncStatus; exports.createSyncManager = createSyncManager; //# sourceMappingURL=index.js.map