// SDK State Synchronization — offline/online sync, multi-tab coordination. // Split: localStorage+BroadcastChannel → sync-storage.ts | conflict → sync-conflict.ts import type { SDKState, SyncState, SyncStatus, ConflictResolution } from '@breakpilot/compliance-sdk-types' import { ComplianceClient } from './client' import { saveStateToLocalStorage, loadStateFromLocalStorage, clearStateFromLocalStorage, createBroadcastChannel, broadcastStateUpdate, broadcastSyncComplete, setupOnlineListener, } from './sync-storage' import { defaultConflictHandler, applyConflictResolution } from './sync-conflict' // --- Types & Constants ------------------------------------------------------- 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 } const DEFAULT_DEBOUNCE_MS = 2000 const DEFAULT_MAX_RETRIES = 3 // --- StateSyncManager -------------------------------------------------------- export class StateSyncManager { private client: ComplianceClient 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 = true constructor( client: ComplianceClient, tenantId: string, options: SyncOptions = {}, callbacks: SyncCallbacks = {} ) { this.client = client this.tenantId = tenantId this.callbacks = callbacks this.options = { debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS, maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, conflictHandler: options.conflictHandler ?? defaultConflictHandler, } this.syncState = { status: 'idle' as SyncStatus, lastSyncedAt: null, localVersion: 0, serverVersion: 0, pendingChanges: 0, error: null, } this.setupBroadcastChannel() this.setupOnlineListener() } // -- Setup -- private setupBroadcastChannel(): void { this.broadcastChannel = createBroadcastChannel(this.tenantId) if (this.broadcastChannel) { this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this) } } private setupOnlineListener(): void { this.isOnline = setupOnlineListener({ onOnline: () => { this.isOnline = true this.syncState.status = 'idle' this.callbacks.onOnline?.() if (this.pendingState) this.syncToServer(this.pendingState) }, onOffline: () => { this.isOnline = false this.syncState.status = 'offline' this.callbacks.onOffline?.() }, }) if (!this.isOnline) this.syncState.status = 'offline' } // -- Broadcast Channel -- private handleBroadcastMessage(event: MessageEvent): void { const { type, state, version } = event.data switch (type) { case 'STATE_UPDATED': if (version > this.syncState.localVersion) { this.syncState.localVersion = version this.saveToLocalStorage(state) this.callbacks.onSyncComplete?.(state) } break case 'SYNC_COMPLETE': this.syncState.serverVersion = version break case 'REQUEST_STATE': this.broadcastCurrentState() break } } private broadcastCurrentState(): void { const state = this.loadFromLocalStorage() if (state) { broadcastStateUpdate(this.broadcastChannel, state, this.syncState.localVersion) } } // -- Local Storage (sync-storage helpers) -- saveToLocalStorage(state: SDKState): void { saveStateToLocalStorage(this.tenantId, state, this.syncState.localVersion) } loadFromLocalStorage(): SDKState | null { return loadStateFromLocalStorage(this.tenantId, this.syncState) } clearLocalStorage(): void { clearStateFromLocalStorage(this.tenantId) } // -- Sync -- queueSync(state: SDKState): void { this.pendingState = state this.syncState.pendingChanges++ this.syncState.localVersion++ this.saveToLocalStorage(state) broadcastStateUpdate(this.broadcastChannel, state, this.syncState.localVersion) if (this.debounceTimeout) { clearTimeout(this.debounceTimeout) } this.debounceTimeout = setTimeout(() => { this.syncToServer(state) }, this.options.debounceMs) } async forceSync(state: SDKState): Promise { if (this.debounceTimeout) { clearTimeout(this.debounceTimeout) this.debounceTimeout = null } await this.syncToServer(state) } 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.client.saveState(state, this.syncState.serverVersion) this.syncState = { ...this.syncState, status: 'idle', lastSyncedAt: new Date(), serverVersion: response.version, pendingChanges: 0, error: null, } this.pendingState = null broadcastSyncComplete(this.broadcastChannel, response.version) this.callbacks.onSyncComplete?.(state) } catch (error) { 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) } } } async loadFromServer(): Promise { if (!this.isOnline) { return this.loadFromLocalStorage() } try { const response = await this.client.getState() if (response) { this.syncState.serverVersion = response.version this.syncState.localVersion = response.version this.saveToLocalStorage(response.state) return response.state } return this.loadFromLocalStorage() } catch (error) { console.error('Failed to load from server:', error) return this.loadFromLocalStorage() } } // -- Conflict Resolution (sync-conflict helpers) -- private async handleConflict(localState: SDKState): Promise { this.syncState.status = 'conflict' try { const serverResponse = await this.client.getState() if (!serverResponse) { await this.client.saveState(localState) return } const serverState = serverResponse.state this.callbacks.onConflict?.(localState, serverState) const resolution = await this.options.conflictHandler(localState, serverState) const resolvedState = applyConflictResolution(resolution, localState, serverState) const response = await this.client.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) } } // -- Getters & Cleanup -- getSyncState(): SyncState { return { ...this.syncState } } isOnlineStatus(): boolean { return this.isOnline } hasPendingChanges(): boolean { return this.syncState.pendingChanges > 0 || this.pendingState !== null } destroy(): void { if (this.debounceTimeout) { clearTimeout(this.debounceTimeout) } if (this.broadcastChannel) { this.broadcastChannel.close() } } } // --- Factory ----------------------------------------------------------------- export function createStateSyncManager( client: ComplianceClient, tenantId: string, options?: SyncOptions, callbacks?: SyncCallbacks ): StateSyncManager { return new StateSyncManager(client, tenantId, options, callbacks) }