cherry-studio/src/renderer/src/data/PreferenceService.ts
fullex 8ea550d566 refactor(preferences): rename and enhance multiple preferences retrieval
- Renamed `Preference_GetMultiple` to `Preference_GetMultipleRaw` in IpcChannel for clarity.
- Introduced `MultiPreferencesResultType` to better map requested keys to their values or undefined in preferenceTypes.
- Updated `PreferenceService` to implement `getMultipleRaw` for synchronous access to multiple preferences.
- Adjusted related components and services to utilize the new method for fetching multiple preferences.
- Cleaned up imports and ensured consistent usage across the application.
2025-12-02 14:03:35 +08:00

707 lines
23 KiB
TypeScript

import { loggerService } from '@logger'
import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas'
import type {
MultiPreferencesResultType,
PreferenceDefaultScopeType,
PreferenceKeyType,
PreferenceUpdateOptions
} from '@shared/data/preference/preferenceTypes'
const logger = loggerService.withContext('PreferenceService')
/**
* Renderer-side PreferenceService providing cached access to preferences with real-time synchronization
*
* Features:
* - Caching system for fast access to frequently used preferences
* - Optimistic and pessimistic update strategies
* - Real-time synchronization across windows via IPC
* - Race condition handling for concurrent updates
* - Batch operations for multiple preferences
* - Integration with React's useSyncExternalStore
*/
export class PreferenceService {
private static instance: PreferenceService
private cache: Record<string, any> = {}
private allChangesListeners = new Set<() => void>()
private keyChangeListeners = new Map<string, Set<() => void>>()
private changeListenerCleanup: (() => void) | null = null
private subscribedKeys = new Set<string>()
private fullCacheLoaded = false
// Optimistic update tracking
private optimisticValues = new Map<
PreferenceKeyType,
{
value: any
originalValue: any
timestamp: number
requestId: string
isFirst: boolean
}
>()
// Request queues for managing concurrent updates to the same key
private requestQueues = new Map<
PreferenceKeyType,
Array<{
requestId: string
value: any
resolve: (value: void | PromiseLike<void>) => void
reject: (reason?: any) => void
}>
>()
private constructor() {
this.setupChangeListeners()
}
/**
* Get the singleton instance of PreferenceService
* @returns The singleton PreferenceService instance
*/
public static getInstance(): PreferenceService {
if (!PreferenceService.instance) {
PreferenceService.instance = new PreferenceService()
}
return PreferenceService.instance
}
/**
* Setup IPC change listener for preference updates from main process
* Establishes communication channel for real-time preference synchronization
*/
private setupChangeListeners() {
if (!window.api?.preference?.onChanged) {
logger.error('Preference API not available in preload context')
return
}
this.changeListenerCleanup = window.api.preference.onChanged((key, value) => {
const oldValue = this.cache[key]
if (oldValue !== value) {
this.cache[key] = value
this.notifyChangeListeners(key)
logger.debug(`Preference ${key} updated to:`, { value })
}
})
}
/**
* Notify all relevant listeners about preference changes
* @param key The preference key that changed
*/
private notifyChangeListeners(key: string) {
// Notify global listeners
this.allChangesListeners.forEach((listener) => listener())
// Notify specific key listeners
const keyListeners = this.keyChangeListeners.get(key)
if (keyListeners) {
keyListeners.forEach((listener) => listener())
}
}
/**
* Get a single preference value with caching and auto-subscription
* @param key The preference key to retrieve
* @returns Promise resolving to the preference value with defaults applied
*/
public async get<K extends PreferenceKeyType>(key: K): Promise<PreferenceDefaultScopeType[K]> {
// Check cache first
if (key in this.cache && this.cache[key] !== undefined) {
return this.cache[key] as PreferenceDefaultScopeType[K]
}
logger.verbose(`get: ${key} not found in cache`)
try {
// Fetch from main process if not cached
const value = await window.api.preference.get(key)
this.cache[key] = value
// since not cached, notify change listeners to receive the value
this.notifyChangeListeners(key)
// Auto-subscribe to this key for future updates
await this.subscribeToKeyInternal([key])
return value
} catch (error) {
logger.error(`Failed to get preference ${key}:`, error as Error)
// Return default value on error
return DefaultPreferences.default[key] as PreferenceDefaultScopeType[K]
}
}
/**
* Set a single preference value with configurable update strategy
* @param key The preference key to update
* @param value The new value to set
* @param options Update strategy options (optimistic by default)
* @returns Promise that resolves when update completes
*/
public async set<K extends PreferenceKeyType>(
key: K,
value: PreferenceDefaultScopeType[K],
options: PreferenceUpdateOptions = { optimistic: true }
): Promise<void> {
if (options.optimistic) {
return this.setOptimistic(key, value)
} else {
return this.setPessimistic(key, value)
}
}
/**
* Optimistic update: Queue request to prevent race conditions
* Updates UI immediately, then syncs to database with rollback on failure
* @param key The preference key to update
* @param value The new value to set
* @returns Promise that resolves when update completes
*/
private async setOptimistic<K extends PreferenceKeyType>(
key: K,
value: PreferenceDefaultScopeType[K]
): Promise<void> {
const requestId = this.generateRequestId()
return this.enqueueRequest(key, requestId, value)
}
/**
* Execute optimistic update with proper race condition handling
* @param key The preference key to update
* @param value The new value to set
* @param requestId Unique identifier for this update request
* @returns Promise that resolves when update completes
*/
private async executeOptimisticUpdate(key: PreferenceKeyType, value: any, requestId: string): Promise<void> {
const existingState = this.optimisticValues.get(key)
const isFirst = !existingState
const originalValue = isFirst ? this.cache[key] : existingState.originalValue
// Update cache immediately for responsive UI
this.cache[key] = value
this.notifyChangeListeners(key)
// Track optimistic state with proper original value protection
this.optimisticValues.set(key, {
value,
originalValue, // Use real original value (from first request) or current if first
timestamp: Date.now(),
requestId,
isFirst
})
logger.debug(`Optimistic update for ${key} (${requestId})${isFirst ? ' [FIRST]' : ''}`)
// Attempt to persist to main process
try {
await window.api.preference.set(key, value)
// Success: confirm optimistic update
this.confirmOptimistic(key, requestId)
logger.debug(`Optimistic update for ${key} (${requestId}) confirmed`)
} catch (error) {
// Failure: rollback optimistic update
this.rollbackOptimistic(key, requestId)
logger.error(`Optimistic update failed for ${key} (${requestId}), rolling back:`, error as Error)
throw error
}
}
/**
* Pessimistic update: Wait for database confirmation before updating UI
* Updates database first, then UI on success
* @param key The preference key to update
* @param value The new value to set
* @returns Promise that resolves when update completes
*/
private async setPessimistic<K extends PreferenceKeyType>(
key: K,
value: PreferenceDefaultScopeType[K]
): Promise<void> {
try {
await window.api.preference.set(key, value)
// Update local cache after successful database update
this.cache[key] = value
this.notifyChangeListeners(key)
logger.debug(`Pessimistic update for ${key} completed`)
} catch (error) {
logger.error(`Pessimistic update failed for ${key}:`, error as Error)
throw error
}
}
/**
* Get multiple preferences at once with caching and auto-subscription
* @param keys Array of preference keys to retrieve
* @returns Promise resolving to object with preference values for requested keys
*/
public async getMultipleRaw<K extends PreferenceKeyType>(keys: K[]): Promise<MultiPreferencesResultType<K>> {
// Check which keys are already cached
const cachedResults: Partial<PreferenceDefaultScopeType> = {}
const uncachedKeys: PreferenceKeyType[] = []
for (const key of keys) {
if (key in this.cache && this.cache[key] !== undefined) {
cachedResults[key] = this.cache[key]
} else {
logger.verbose(`getMultiple: ${key} not found in cache`)
uncachedKeys.push(key)
}
}
// Fetch uncached keys from main process
if (uncachedKeys.length > 0) {
try {
const uncachedResults = await window.api.preference.getMultipleRaw(uncachedKeys)
// Update cache with new results
for (const [key, value] of Object.entries(uncachedResults)) {
this.cache[key as PreferenceKeyType] = value
this.notifyChangeListeners(key)
await this.subscribeToKeyInternal([key as PreferenceKeyType])
}
return { ...cachedResults, ...uncachedResults } as MultiPreferencesResultType<K>
} catch (error) {
logger.error('Failed to get multiple preferences:', error as Error)
// Fill in default values for failed keys
const defaultResults: Partial<PreferenceDefaultScopeType> = {}
for (const key of uncachedKeys) {
if (key in DefaultPreferences.default) {
;(defaultResults as any)[key] = DefaultPreferences.default[key]
}
}
return { ...cachedResults, ...defaultResults } as MultiPreferencesResultType<K>
}
}
return cachedResults as MultiPreferencesResultType<K>
}
/**
* Get multiple preferences at once and return them as a record of key-value pairs
* @param keys Object mapping local names to preference keys
* @returns Promise resolving to object with mapped preference values
*/
public async getMultiple<T extends Record<string, PreferenceKeyType>>(
keys: T
): Promise<{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] }> {
const values = await this.getMultipleRaw(Object.values(keys))
const result = {} as { [P in keyof T]: PreferenceDefaultScopeType[T[P]] }
for (const key in keys) {
result[key] = values[keys[key]]!
}
return result
}
/**
* Set multiple preferences at once with configurable update strategy
* @param updates Object containing preference key-value pairs to update
* @param options Update strategy options (optimistic by default)
* @returns Promise that resolves when all updates complete
*/
public async setMultiple(
updates: Partial<PreferenceDefaultScopeType>,
options: PreferenceUpdateOptions = { optimistic: true }
): Promise<void> {
if (options.optimistic) {
return this.setMultipleOptimistic(updates)
} else {
return this.setMultiplePessimistic(updates)
}
}
/**
* Optimistic batch update: Update UI immediately, then sync to database
* @param updates Object containing preference key-value pairs to update
* @returns Promise that resolves when batch update completes
*/
private async setMultipleOptimistic(updates: Partial<PreferenceDefaultScopeType>): Promise<void> {
const batchRequestId = this.generateRequestId()
const originalValues: Record<string, any> = {}
const keysToUpdate = Object.keys(updates) as PreferenceKeyType[]
// For batch updates, we need to check for existing optimistic states
// and preserve the original values from first requests
for (const key of keysToUpdate) {
const existingState = this.optimisticValues.get(key)
originalValues[key] = existingState ? existingState.originalValue : this.cache[key as PreferenceKeyType]
}
// Update cache immediately and track original values
for (const [key, value] of Object.entries(updates)) {
this.cache[key as PreferenceKeyType] = value
this.notifyChangeListeners(key)
}
// Track optimistic states for all keys with proper original value protection
const timestamp = Date.now()
keysToUpdate.forEach((key) => {
const existingState = this.optimisticValues.get(key)
const isFirst = !existingState
this.optimisticValues.set(key, {
value: updates[key as PreferenceKeyType],
originalValue: originalValues[key], // Use protected original value
timestamp,
requestId: `${batchRequestId}_${key}`, // Unique ID per key in batch
isFirst
})
})
logger.debug(`Optimistic batch update for ${keysToUpdate.length} preferences (${batchRequestId})`)
// Attempt to persist to main process
try {
await window.api.preference.setMultiple(updates)
// Success: confirm all optimistic updates
keysToUpdate.forEach((key) => this.confirmOptimistic(key, `${batchRequestId}_${key}`))
logger.debug(`Optimistic batch update confirmed for ${keysToUpdate.length} preferences (${batchRequestId})`)
} catch (error) {
// Failure: rollback all optimistic updates
keysToUpdate.forEach((key) => this.rollbackOptimistic(key, `${batchRequestId}_${key}`))
logger.error(
`Optimistic batch update failed, rolling back ${keysToUpdate.length} preferences (${batchRequestId}):`,
error as Error
)
throw error
}
}
/**
* Pessimistic batch update: Wait for database confirmation before updating UI
* @param updates Object containing preference key-value pairs to update
* @returns Promise that resolves when batch update completes
*/
private async setMultiplePessimistic(updates: Partial<PreferenceDefaultScopeType>): Promise<void> {
try {
await window.api.preference.setMultiple(updates)
// Update local cache for all updated values after successful database update
for (const [key, value] of Object.entries(updates)) {
this.cache[key as PreferenceKeyType] = value
this.notifyChangeListeners(key)
}
logger.debug(`Pessimistic batch update completed for ${Object.keys(updates).length} preferences`)
} catch (error) {
logger.error(`Pessimistic batch update failed:`, error as Error)
throw error
}
}
/**
* Subscribe to specific keys for change notifications from main process
* @param keys Array of preference keys to subscribe to
* @returns Promise that resolves when subscription is established
*/
private async subscribeToKeyInternal(keys: PreferenceKeyType[]): Promise<void> {
const keysToSubscribe = keys.filter((key) => !this.subscribedKeys.has(key))
if (keysToSubscribe.length === 0) return
try {
await window.api.preference.subscribe(keysToSubscribe)
keysToSubscribe.forEach((key) => this.subscribedKeys.add(key))
logger.verbose(`Subscribed to preference keys: ${keysToSubscribe.join(', ')}`)
} catch (error) {
logger.error(`Failed to subscribe to preference keys ${keysToSubscribe.join(', ')}:`, error as Error)
}
}
/**
* Subscribe to global preference changes (for useSyncExternalStore)
* @param callback Function to call when any preference changes
* @returns Unsubscribe function
*/
public subscribeAllChanges = (callback: () => void): (() => void) => {
this.allChangesListeners.add(callback)
return () => {
this.allChangesListeners.delete(callback)
}
}
/**
* Subscribe to specific key changes (for useSyncExternalStore)
* @param key The preference key to watch for changes
* @returns Function that takes a callback and returns an unsubscribe function
*/
public subscribeChange =
(key: PreferenceKeyType) =>
(callback: () => void): (() => void) => {
if (!this.keyChangeListeners.has(key)) {
this.keyChangeListeners.set(key, new Set())
}
const keyListeners = this.keyChangeListeners.get(key)!
keyListeners.add(callback)
// Auto-subscribe to this key for updates
this.subscribeToKeyInternal([key])
return () => {
keyListeners.delete(callback)
if (keyListeners.size === 0) {
this.keyChangeListeners.delete(key)
}
}
}
/**
* Get cached value without async fetch
* @param key The preference key to retrieve from cache
* @returns The cached value or undefined if not cached
*/
public getCachedValue<K extends PreferenceKeyType>(key: K): PreferenceDefaultScopeType[K] | undefined {
return this.cache[key]
}
/**
* Check if a preference is cached
* @param key The preference key to check
* @returns True if the key is cached, false otherwise
*/
public isCached(key: PreferenceKeyType): boolean {
return key in this.cache && this.cache[key] !== undefined
}
/**
* Load all preferences from main process at once for optimal performance
* @returns Promise resolving to all preference values
*/
public async preloadAll(): Promise<PreferenceDefaultScopeType> {
try {
const allPreferences = await window.api.preference.getAll()
// Update local cache with all preferences
for (const [key, value] of Object.entries(allPreferences)) {
this.cache[key as PreferenceKeyType] = value
// Notify change listeners for the loaded value
this.notifyChangeListeners(key)
}
await this.subscribeToKeyInternal(Object.keys(allPreferences) as PreferenceKeyType[])
this.fullCacheLoaded = true
logger.info(`Loaded all ${Object.keys(allPreferences).length} preferences into cache`)
return allPreferences
} catch (error) {
logger.error('Failed to load all preferences:', error as Error)
throw error
}
}
/**
* Check if all preferences are loaded in cache
* @returns True if full cache has been loaded, false otherwise
*/
public isFullyCached(): boolean {
return this.fullCacheLoaded
}
/**
* Preload specific preferences into cache
* @param keys Array of preference keys to preload
* @returns Promise that resolves when preloading completes
*/
public async preload(keys: PreferenceKeyType[]): Promise<void> {
const uncachedKeys = keys.filter((key) => !this.isCached(key))
if (uncachedKeys.length > 0) {
try {
const values = await this.getMultipleRaw(uncachedKeys)
logger.debug(`Preloaded ${Object.keys(values).length} preferences`)
} catch (error) {
logger.error('Failed to preload preferences:', error as Error)
}
}
}
/**
* Confirm an optimistic update when main process confirms the update
* @param key The preference key that was updated
* @param requestId The unique identifier for the update request
*/
private confirmOptimistic(key: PreferenceKeyType, requestId: string): void {
const optimisticState = this.optimisticValues.get(key)
if (optimisticState && optimisticState.requestId === requestId) {
this.optimisticValues.delete(key)
logger.debug(`Optimistic update confirmed for ${key} (${requestId})`)
// Process next queued request
this.completeQueuedRequest(key)
} else {
logger.warn(
`Attempted to confirm mismatched request for ${key}: expected ${optimisticState?.requestId}, got ${requestId}`
)
}
}
/**
* Rollback an optimistic update when main process update fails
* @param key The preference key to rollback
* @param requestId The unique identifier for the failed update request
*/
private rollbackOptimistic(key: PreferenceKeyType, requestId: string): void {
const optimisticState = this.optimisticValues.get(key)
if (optimisticState && optimisticState.requestId === requestId) {
// Restore original value (the real original value from first request)
this.cache[key] = optimisticState.originalValue
this.notifyChangeListeners(key)
// Clear optimistic state
this.optimisticValues.delete(key)
const duration = Date.now() - optimisticState.timestamp
logger.warn(`Optimistic update rolled back for ${key} (${requestId}) after ${duration}ms to original value`)
// Process next queued request
this.completeQueuedRequest(key)
} else {
logger.warn(
`Attempted to rollback mismatched request for ${key}: expected ${optimisticState?.requestId}, got ${requestId}`
)
}
}
/**
* Get all pending optimistic updates for debugging purposes
* @returns Array of pending optimistic update information
*/
public getPendingOptimisticUpdates(): Array<{
key: string
value: any
originalValue: any
timestamp: number
requestId: string
isFirst: boolean
}> {
return Array.from(this.optimisticValues.entries()).map(([key, state]) => ({
key,
value: state.value,
originalValue: state.originalValue,
timestamp: state.timestamp,
requestId: state.requestId,
isFirst: state.isFirst
}))
}
/**
* Generate unique request ID for tracking concurrent requests
* @returns Unique request identifier string
*/
private generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
}
/**
* Add request to queue for a specific key to prevent race conditions
* @param key The preference key to update
* @param requestId Unique identifier for this request
* @param value The value to set
* @returns Promise that resolves when the request is processed
*/
private enqueueRequest(key: PreferenceKeyType, requestId: string, value: any): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (!this.requestQueues.has(key)) {
this.requestQueues.set(key, [])
}
const queue = this.requestQueues.get(key)!
queue.push({ requestId, value, resolve, reject })
// If this is the first request in queue, process it immediately
if (queue.length === 1) {
this.processNextQueuedRequest(key)
}
})
}
/**
* Process the next queued request for a key
* @param key The preference key to process requests for
* @returns Promise that resolves when processing completes
*/
private async processNextQueuedRequest(key: PreferenceKeyType): Promise<void> {
const queue = this.requestQueues.get(key)
if (!queue || queue.length === 0) {
return
}
const currentRequest = queue[0]
try {
await this.executeOptimisticUpdate(key, currentRequest.value, currentRequest.requestId)
currentRequest.resolve()
} catch (error) {
currentRequest.reject(error)
}
}
/**
* Complete current request and process next in queue
* @param key The preference key to complete processing for
*/
private completeQueuedRequest(key: PreferenceKeyType): void {
const queue = this.requestQueues.get(key)
if (queue && queue.length > 0) {
queue.shift() // Remove completed request
// Process next request if any
if (queue.length > 0) {
this.processNextQueuedRequest(key)
} else {
// Clean up empty queue
this.requestQueues.delete(key)
}
}
}
/**
* Clear all cached preferences for testing/debugging
*/
public clearCache(): void {
this.cache = {}
this.fullCacheLoaded = false
logger.debug('Preference cache cleared')
}
/**
* Cleanup service resources - call when shutting down
*/
public cleanup(): void {
if (this.changeListenerCleanup) {
this.changeListenerCleanup()
this.changeListenerCleanup = null
}
// Clear all optimistic states and request queues
this.optimisticValues.clear()
this.requestQueues.clear()
this.clearCache()
this.allChangesListeners.clear()
this.keyChangeListeners.clear()
this.subscribedKeys.clear()
}
}
// Export singleton instance
export const preferenceService = PreferenceService.getInstance()
export default preferenceService