Files
breakpilot-compliance/admin-compliance/lib/sdk/sync.ts
Benjamin Boenisch 4435e7ea0a Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:28 +01:00

483 lines
13 KiB
TypeScript

/**
* 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<ConflictResolution>
}
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<SyncOptions>
private callbacks: SyncCallbacks
private syncState: SyncState
private broadcastChannel: BroadcastChannel | null = null
private debounceTimeout: ReturnType<typeof setTimeout> | 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<void> {
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
this.debounceTimeout = null
}
await this.syncToServer(state)
}
/**
* Sync state to server
*/
private async syncToServer(state: SDKState): Promise<void> {
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<SDKState | null> {
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<void> {
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<ConflictResolution> {
// 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)
}