UNPKG

@bernierllc/content-autosave-manager

Version:

Automatic content saving with debouncing, retry logic, and conflict detection

250 lines (248 loc) 9.78 kB
"use strict"; /* Copyright (c) 2025 Bernier LLC This file is licensed to the client under a limited-use license. The client may use and modify this code *only within the scope of the project it was delivered for*. Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.AutosaveManager = void 0; const backoff_retry_1 = require("@bernierllc/backoff-retry"); const config_1 = require("./config"); class AutosaveManager { constructor(config) { this.config = (0, config_1.mergeWithDefaults)(config); this.activeItems = new Map(); this.statusListeners = []; } enable(contentId, saveFunction, config) { const itemConfig = config ? (0, config_1.mergeWithDefaults)({ ...this.config, ...config }) : this.config; const state = { contentId, saveFunction, config: itemConfig, status: 'idle', version: 0, debounceTimer: null, retryManager: new backoff_retry_1.RetryManager({ defaultOptions: { maxRetries: itemConfig.maxRetries, initialDelayMs: 1000, maxDelayMs: 30000, jitter: true, backoffFactor: itemConfig.retryStrategy === 'exponential' ? 2 : 1, }, }), }; this.activeItems.set(contentId, state); this.emitStatus(contentId, 'idle'); } disable(contentId) { const state = this.activeItems.get(contentId); if (!state) return; if (state.debounceTimer) { clearTimeout(state.debounceTimer); } this.activeItems.delete(contentId); } queueSave(contentId, content) { const state = this.activeItems.get(contentId); if (!state) { throw new Error(`Autosave not enabled for content: ${contentId}`); } // Clear existing debounce timer if (state.debounceTimer) { clearTimeout(state.debounceTimer); } // Store latest content state.pendingContent = content; // Start new debounce timer state.debounceTimer = setTimeout(() => { this.executeSave(contentId, content); }, state.config.debounceMs); // Update status to indicate content has changed if (state.status === 'saved') { this.emitStatus(contentId, 'idle'); } } async forceSave(contentId, content) { const state = this.activeItems.get(contentId); if (!state) { throw new Error(`Autosave not enabled for content: ${contentId}`); } // Clear debounce timer if (state.debounceTimer) { clearTimeout(state.debounceTimer); state.debounceTimer = null; } return this.executeSave(contentId, content); } getStatus(contentId) { const state = this.activeItems.get(contentId); if (!state) return null; return { contentId, status: state.status, version: state.version, lastSaved: state.lastSaved, error: state.lastError, }; } onConflict(handler) { this.conflictHandler = handler; } onStatusChange(listener) { this.statusListeners.push(listener); } clear(contentId) { const state = this.activeItems.get(contentId); if (state?.debounceTimer) { clearTimeout(state.debounceTimer); } this.activeItems.delete(contentId); } destroy() { for (const [contentId] of this.activeItems) { this.disable(contentId); } this.statusListeners = []; this.conflictHandler = undefined; } async executeSave(contentId, content) { const state = this.activeItems.get(contentId); if (!state) { return { success: false, error: 'Autosave state not found' }; } this.emitStatus(contentId, 'saving'); // Manual retry logic since RetryManager may not work well with non-throwing errors let lastResult; let attempts = 0; const maxRetries = state.config.maxRetries; while (attempts <= maxRetries) { attempts++; try { const nextVersion = state.config.enableVersioning ? state.version + 1 : undefined; const result = await state.saveFunction(contentId, content, nextVersion); if (result.success) { // Handle successful save // If server returns a version, use it; otherwise use the version we sent if (result.version !== undefined) { state.version = result.version; } else if (nextVersion !== undefined) { state.version = nextVersion; } state.lastSaved = new Date(); state.lastError = undefined; state.pendingContent = undefined; this.emitStatus(contentId, 'saved'); return result; } // Handle conflict if (result.conflict) { if (state.config.enableConflictDetection) { const resolution = await this.handleConflict(contentId, content, result); if (resolution === 'local') { // Retry save with conflict resolution return this.executeSave(contentId, content); } else if (resolution === 'server') { // Accept server version state.version = result.version ?? state.version; state.lastSaved = new Date(); this.emitStatus(contentId, 'saved'); return { success: true, version: state.version }; } // Manual resolution - leave in error state } // Conflict detected but no handler or detection disabled - return error immediately state.lastError = result.error; this.emitStatus(contentId, 'error', result.error); return result; } // Save failed but no conflict - store result and maybe retry lastResult = result; // If we have more retries, wait with exponential backoff if (attempts <= maxRetries) { const delay = this.calculateBackoffDelay(attempts, state.config); await new Promise(resolve => setTimeout(resolve, delay)); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; lastResult = { success: false, error: errorMessage }; // If we have more retries, wait with exponential backoff if (attempts <= maxRetries) { const delay = this.calculateBackoffDelay(attempts, state.config); await new Promise(resolve => setTimeout(resolve, delay)); } } } // All retries exhausted const errorMessage = lastResult?.error || 'Save failed after retries'; state.lastError = errorMessage; this.emitStatus(contentId, 'error', errorMessage); return lastResult || { success: false, error: errorMessage }; } calculateBackoffDelay(attempt, config) { const baseDelay = 1000; const maxDelay = 30000; let delay; switch (config.retryStrategy) { case 'linear': delay = baseDelay * attempt; break; case 'fibonacci': { const fib = (n) => (n <= 1 ? 1 : fib(n - 1) + fib(n - 2)); delay = baseDelay * fib(attempt); break; } case 'exponential': default: delay = baseDelay * Math.pow(2, attempt - 1); break; } // Apply max delay cap delay = Math.min(delay, maxDelay); // Add jitter (random variation of ±20%) const jitter = delay * 0.2 * (Math.random() * 2 - 1); return Math.max(0, delay + jitter); } async handleConflict(contentId, localContent, saveResult) { const state = this.activeItems.get(contentId); if (!state || !saveResult.serverContent) { return 'manual'; } const conflictEvent = { contentId, localContent, localVersion: state.version, serverContent: saveResult.serverContent, serverVersion: saveResult.version ?? 0, }; if (this.conflictHandler) { return this.conflictHandler(conflictEvent); } // Default: manual resolution required return 'manual'; } emitStatus(contentId, status, error) { const state = this.activeItems.get(contentId); if (!state) return; state.status = status; const event = { contentId, status, version: state.version, lastSaved: state.lastSaved, error, }; for (const listener of this.statusListeners) { listener(event); } } } exports.AutosaveManager = AutosaveManager;