/** * Optimistic Updates Store * * Manages optimistic mutations with rollback capability. * Tracks applied mutations until server confirmation. */ import type { BaseRecord, AppliedMutation, OptimisticState, } from '../types' /** * Options for creating an optimistic store */ export interface OptimisticStoreOptions { collectionId: string } /** * OptimisticStore - Manages optimistic state for a collection */ export class OptimisticStore { private collectionId: string private state: OptimisticState = { pending: [], applied: [], } private subscribers: Set<(state: OptimisticState) => void> = new Set() private rollbackHandlers: Array<(event: { mutationId: string; type: string; recordId: string | number }) => void> = [] private mergedStates: Map> = new Map() constructor(options: OptimisticStoreOptions) { this.collectionId = options.collectionId } /** * Apply an optimistic mutation */ applyOptimistic( type: 'insert' | 'update' | 'delete', recordId: string | number, data: Partial, previousData?: T ): string { const mutationId = this.generateMutationId() const mutation: AppliedMutation = { id: mutationId, type, collectionId: this.collectionId, recordId, data, timestamp: Date.now(), } if (previousData !== undefined) { mutation.previousData = previousData } this.state = { ...this.state, applied: [...this.state.applied, mutation], } this.notifySubscribers() return mutationId } /** * Confirm a mutation was successfully persisted */ confirmMutation(mutationId: string): void { this.state = { ...this.state, applied: this.state.applied.filter(m => m.id !== mutationId), } this.notifySubscribers() } /** * Rollback a specific mutation */ rollbackMutation(mutationId: string): AppliedMutation | undefined { const mutation = this.state.applied.find(m => m.id === mutationId) if (!mutation) return undefined this.state = { ...this.state, applied: this.state.applied.filter(m => m.id !== mutationId), } this.emitRollback(mutation) this.notifySubscribers() return mutation } /** * Rollback all mutations in reverse order */ rollbackAll(): AppliedMutation[] { const mutations = [...this.state.applied].reverse() this.state = { ...this.state, applied: [], } this.notifySubscribers() return mutations } /** * Rollback all mutations and return them in reverse order for restore */ rollbackAllWithRestore(): AppliedMutation[] { const mutations = [...this.state.applied].reverse() this.state = { ...this.state, applied: [], } this.notifySubscribers() return mutations } /** * Rollback all mutations after a given ID (exclusive) */ rollbackAfter(afterId: string): void { const index = this.state.applied.findIndex(m => m.id === afterId) if (index === -1) return const rolledBack = this.state.applied.slice(index + 1) this.state = { ...this.state, applied: this.state.applied.slice(0, index + 1), } // Notify rollback handlers for (const mutation of rolledBack.reverse()) { this.emitRollback(mutation) } this.notifySubscribers() } /** * Register a rollback event handler */ onRollback(handler: (event: { mutationId: string; type: string; recordId: string | number }) => void): () => void { this.rollbackHandlers.push(handler) return () => { const index = this.rollbackHandlers.indexOf(handler) if (index >= 0) this.rollbackHandlers.splice(index, 1) } } /** * Get merged optimistic state for a specific record */ getMergedOptimisticState(recordId: string | number): Partial { const mutations = this.state.applied.filter(m => m.recordId === recordId) const merged: Partial = {} for (const mutation of mutations) { Object.assign(merged, mutation.data) } return merged } /** * Rollback with custom merge strategy */ rollbackWithMerge(mutationId: string, mergeFn: (local: Partial, server: T) => T): void { const mutation = this.state.applied.find(m => m.id === mutationId) if (mutation && mutation.previousData) { const merged = mergeFn(mutation.data, mutation.previousData) this.mergedStates.set(mutation.recordId, merged as Partial) } this.state = { ...this.state, applied: this.state.applied.filter(m => m.id !== mutationId), } this.notifySubscribers() } /** * Get merged state for a record (combining server + optimistic + merge results) */ getMergedState(recordId: string | number): Partial { const mergedFromRollback = this.mergedStates.get(recordId) if (mergedFromRollback) { return mergedFromRollback } return this.getMergedOptimisticState(recordId) } /** * Get current optimistic state */ getState(): OptimisticState { return { pending: [...this.state.pending], applied: [...this.state.applied], } } /** * Check if there are pending mutations */ hasPendingMutations(): boolean { return this.state.applied.length > 0 } /** * Get mutations for a specific record */ getMutationsForRecord(recordId: string | number): AppliedMutation[] { return this.state.applied.filter(m => m.recordId === recordId) } /** * Subscribe to state changes */ subscribe(callback: (state: OptimisticState) => void): () => void { this.subscribers.add(callback) return () => { this.subscribers.delete(callback) } } /** * Get the current subscriber count for debugging */ getSubscriberCount(): number { return this.subscribers.size } /** * Dispose all resources */ dispose(): void { this.state = { pending: [], applied: [], } this.subscribers.clear() } private generateMutationId(): string { return `opt-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } private emitRollback(mutation: AppliedMutation): void { for (const handler of this.rollbackHandlers) { handler({ mutationId: mutation.id, type: mutation.type, recordId: mutation.recordId, }) } } private notifySubscribers(): void { const state = this.getState() for (const callback of this.subscribers) { callback(state) } } } /** * Factory function to create an OptimisticStore */ export function createOptimisticStore( options: OptimisticStoreOptions ): OptimisticStore { return new OptimisticStore(options) } /** * Apply an optimistic update to a data map (immutably) */ export function applyOptimisticUpdate( data: Map, type: 'insert' | 'update' | 'delete', id: string | number, changes: Partial ): Map { const result = new Map(data) switch (type) { case 'insert': result.set(id, changes as T) break case 'update': { const existing = result.get(id) if (existing) { result.set(id, { ...existing, ...changes }) } break } case 'delete': result.delete(id) break } return result } /** * Rollback an optimistic update (immutably) */ export function rollbackOptimisticUpdate( data: Map, type: 'insert' | 'update' | 'delete', id: string | number, previousData?: T ): Map { const result = new Map(data) switch (type) { case 'insert': // Remove the inserted item result.delete(id) break case 'update': // Restore previous value if (previousData) { result.set(id, previousData) } break case 'delete': // Restore deleted item if (previousData) { result.set(id, previousData) } break } return result }