@bernierllc/content-autosave-manager
Version:
Automatic content saving with debouncing, retry logic, and conflict detection
250 lines (248 loc) • 9.78 kB
JavaScript
"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;