/** * SDK State Synchronization * * Handles offline/online sync, multi-tab coordination, * and conflict resolution for SDK state. */ import { SDKState } from './types' import { SDKApiClient, StateResponse } from './api-client' // ============================================================================= // TYPES // ============================================================================= export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline' | 'conflict' export interface SyncState { status: SyncStatus lastSyncedAt: Date | null localVersion: number serverVersion: number pendingChanges: number error: string | null } export interface ConflictResolution { strategy: 'local' | 'server' | 'merge' mergedState?: SDKState } export interface SyncOptions { debounceMs?: number maxRetries?: number conflictHandler?: (local: SDKState, server: SDKState) => Promise } export interface SyncCallbacks { onSyncStart?: () => void onSyncComplete?: (state: SDKState) => void onSyncError?: (error: Error) => void onConflict?: (local: SDKState, server: SDKState) => void onOffline?: () => void onOnline?: () => void } // ============================================================================= // CONSTANTS // ============================================================================= const STORAGE_KEY_PREFIX = 'ai-compliance-sdk-state' const SYNC_CHANNEL = 'sdk-state-sync' const DEFAULT_DEBOUNCE_MS = 2000 const DEFAULT_MAX_RETRIES = 3 // ============================================================================= // STATE SYNC MANAGER // ============================================================================= export class StateSyncManager { private apiClient: SDKApiClient private tenantId: string private options: Required private callbacks: SyncCallbacks private syncState: SyncState private broadcastChannel: BroadcastChannel | null = null private debounceTimeout: ReturnType | null = null private pendingState: SDKState | null = null private isOnline: boolean = true constructor( apiClient: SDKApiClient, tenantId: string, options: SyncOptions = {}, callbacks: SyncCallbacks = {} ) { this.apiClient = apiClient this.tenantId = tenantId this.callbacks = callbacks this.options = { debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS, maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, conflictHandler: options.conflictHandler ?? this.defaultConflictHandler, } this.syncState = { status: 'idle', lastSyncedAt: null, localVersion: 0, serverVersion: 0, pendingChanges: 0, error: null, } this.setupBroadcastChannel() this.setupOnlineListener() } // --------------------------------------------------------------------------- // Setup Methods // --------------------------------------------------------------------------- private setupBroadcastChannel(): void { if (typeof window === 'undefined' || !('BroadcastChannel' in window)) { return } try { this.broadcastChannel = new BroadcastChannel(`${SYNC_CHANNEL}-${this.tenantId}`) this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this) } catch (error) { console.warn('BroadcastChannel not available:', error) } } private setupOnlineListener(): void { if (typeof window === 'undefined') { return } window.addEventListener('online', () => { this.isOnline = true this.syncState.status = 'idle' this.callbacks.onOnline?.() // Attempt to sync any pending changes if (this.pendingState) { this.syncToServer(this.pendingState) } }) window.addEventListener('offline', () => { this.isOnline = false this.syncState.status = 'offline' this.callbacks.onOffline?.() }) // Check initial online status this.isOnline = navigator.onLine if (!this.isOnline) { this.syncState.status = 'offline' } } // --------------------------------------------------------------------------- // Broadcast Channel Methods // --------------------------------------------------------------------------- private handleBroadcastMessage(event: MessageEvent): void { const { type, state, version, tabId } = event.data switch (type) { case 'STATE_UPDATED': // Another tab updated the state if (version > this.syncState.localVersion) { this.syncState.localVersion = version this.saveToLocalStorage(state) this.callbacks.onSyncComplete?.(state) } break case 'SYNC_COMPLETE': // Another tab completed a sync this.syncState.serverVersion = version break case 'REQUEST_STATE': // Another tab is requesting the current state this.broadcastState() break } } private broadcastState(): void { if (!this.broadcastChannel) return const state = this.loadFromLocalStorage() if (state) { this.broadcastChannel.postMessage({ type: 'STATE_UPDATED', state, version: this.syncState.localVersion, tabId: this.getTabId(), }) } } private broadcastSyncComplete(version: number): void { if (!this.broadcastChannel) return this.broadcastChannel.postMessage({ type: 'SYNC_COMPLETE', version, tabId: this.getTabId(), }) } private getTabId(): string { if (typeof window === 'undefined') return 'server' let tabId = sessionStorage.getItem('sdk-tab-id') if (!tabId) { tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` sessionStorage.setItem('sdk-tab-id', tabId) } return tabId } // --------------------------------------------------------------------------- // Local Storage Methods // --------------------------------------------------------------------------- private getStorageKey(): string { return `${STORAGE_KEY_PREFIX}-${this.tenantId}` } saveToLocalStorage(state: SDKState): void { if (typeof window === 'undefined') return try { const data = { state, version: this.syncState.localVersion, savedAt: new Date().toISOString(), } localStorage.setItem(this.getStorageKey(), JSON.stringify(data)) } catch (error) { console.error('Failed to save to localStorage:', error) } } loadFromLocalStorage(): SDKState | null { if (typeof window === 'undefined') return null try { const stored = localStorage.getItem(this.getStorageKey()) if (stored) { const data = JSON.parse(stored) this.syncState.localVersion = data.version || 0 return data.state } } catch (error) { console.error('Failed to load from localStorage:', error) } return null } clearLocalStorage(): void { if (typeof window === 'undefined') return try { localStorage.removeItem(this.getStorageKey()) } catch (error) { console.error('Failed to clear localStorage:', error) } } // --------------------------------------------------------------------------- // Sync Methods // --------------------------------------------------------------------------- /** * Queue a state change for syncing (debounced) */ queueSync(state: SDKState): void { this.pendingState = state this.syncState.pendingChanges++ // Save to localStorage immediately this.syncState.localVersion++ this.saveToLocalStorage(state) // Broadcast to other tabs this.broadcastState() // Debounce server sync if (this.debounceTimeout) { clearTimeout(this.debounceTimeout) } this.debounceTimeout = setTimeout(() => { this.syncToServer(state) }, this.options.debounceMs) } /** * Force immediate sync to server */ async forcSync(state: SDKState): Promise { if (this.debounceTimeout) { clearTimeout(this.debounceTimeout) this.debounceTimeout = null } await this.syncToServer(state) } /** * Sync state to server */ private async syncToServer(state: SDKState): Promise { if (!this.isOnline) { this.syncState.status = 'offline' return } this.syncState.status = 'syncing' this.callbacks.onSyncStart?.() try { const response = await this.apiClient.saveState(state, this.syncState.serverVersion) this.syncState = { ...this.syncState, status: 'idle', lastSyncedAt: new Date(), serverVersion: response.version, pendingChanges: 0, error: null, } this.pendingState = null this.broadcastSyncComplete(response.version) this.callbacks.onSyncComplete?.(state) } catch (error) { // Handle version conflict (409) if ((error as { status?: number }).status === 409) { await this.handleConflict(state) } else { this.syncState.status = 'error' this.syncState.error = (error as Error).message this.callbacks.onSyncError?.(error as Error) } } } /** * Load state from server */ async loadFromServer(): Promise { if (!this.isOnline) { return this.loadFromLocalStorage() } try { const response = await this.apiClient.getState() if (response) { this.syncState.serverVersion = response.version this.syncState.localVersion = response.version this.saveToLocalStorage(response.state) return response.state } // No server state, return local if available return this.loadFromLocalStorage() } catch (error) { console.error('Failed to load from server:', error) // Fallback to local storage return this.loadFromLocalStorage() } } // --------------------------------------------------------------------------- // Conflict Resolution // --------------------------------------------------------------------------- private async handleConflict(localState: SDKState): Promise { this.syncState.status = 'conflict' try { // Fetch server state const serverResponse = await this.apiClient.getState() if (!serverResponse) { // Server has no state, use local await this.apiClient.saveState(localState) return } const serverState = serverResponse.state this.callbacks.onConflict?.(localState, serverState) // Resolve conflict const resolution = await this.options.conflictHandler(localState, serverState) let resolvedState: SDKState switch (resolution.strategy) { case 'local': resolvedState = localState break case 'server': resolvedState = serverState break case 'merge': resolvedState = resolution.mergedState || localState break } // Save resolved state const response = await this.apiClient.saveState(resolvedState) this.syncState.serverVersion = response.version this.syncState.localVersion = response.version this.saveToLocalStorage(resolvedState) this.syncState.status = 'idle' this.callbacks.onSyncComplete?.(resolvedState) } catch (error) { this.syncState.status = 'error' this.syncState.error = (error as Error).message this.callbacks.onSyncError?.(error as Error) } } private async defaultConflictHandler( local: SDKState, server: SDKState ): Promise { // Default: Server wins, but we preserve certain local-only data const localTime = new Date(local.lastModified).getTime() const serverTime = new Date(server.lastModified).getTime() if (localTime > serverTime) { // Local is newer, use local return { strategy: 'local' } } // Merge: Use server state but preserve local UI preferences const mergedState: SDKState = { ...server, preferences: local.preferences, commandBarHistory: [ ...local.commandBarHistory, ...server.commandBarHistory.filter( h => !local.commandBarHistory.some(lh => lh.id === h.id) ), ].slice(0, 50), recentSearches: [...new Set([...local.recentSearches, ...server.recentSearches])].slice(0, 20), } return { strategy: 'merge', mergedState } } // --------------------------------------------------------------------------- // Getters & Cleanup // --------------------------------------------------------------------------- getSyncState(): SyncState { return { ...this.syncState } } isOnlineStatus(): boolean { return this.isOnline } hasPendingChanges(): boolean { return this.syncState.pendingChanges > 0 || this.pendingState !== null } /** * Cleanup resources */ destroy(): void { if (this.debounceTimeout) { clearTimeout(this.debounceTimeout) } if (this.broadcastChannel) { this.broadcastChannel.close() } } } // ============================================================================= // FACTORY FUNCTION // ============================================================================= export function createStateSyncManager( apiClient: SDKApiClient, tenantId: string, options?: SyncOptions, callbacks?: SyncCallbacks ): StateSyncManager { return new StateSyncManager(apiClient, tenantId, options, callbacks) }