Files
breakpilot-compliance/breakpilot-compliance-sdk/packages/core/src/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

436 lines
12 KiB
TypeScript

/**
* SDK State Synchronization
*
* Handles offline/online sync, multi-tab coordination,
* and conflict resolution for SDK state.
*/
import type { SDKState, SyncState, SyncStatus, ConflictResolution } from '@breakpilot/compliance-sdk-types'
import { ComplianceClient } from './client'
// =============================================================================
// TYPES
// =============================================================================
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 = 'breakpilot-compliance-sdk-state'
const SYNC_CHANNEL = 'breakpilot-sdk-state-sync'
const DEFAULT_DEBOUNCE_MS = 2000
const DEFAULT_MAX_RETRIES = 3
// =============================================================================
// STATE SYNC MANAGER
// =============================================================================
export class StateSyncManager {
private client: ComplianceClient
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 = 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 ?? this.defaultConflictHandler.bind(this),
}
this.syncState = {
status: 'idle' as SyncStatus,
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?.()
if (this.pendingState) {
this.syncToServer(this.pendingState)
}
})
window.addEventListener('offline', () => {
this.isOnline = false
this.syncState.status = 'offline'
this.callbacks.onOffline?.()
})
this.isOnline = navigator.onLine
if (!this.isOnline) {
this.syncState.status = 'offline'
}
}
// ---------------------------------------------------------------------------
// Broadcast Channel Methods
// ---------------------------------------------------------------------------
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.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('breakpilot-sdk-tab-id')
if (!tabId) {
tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
sessionStorage.setItem('breakpilot-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
// ---------------------------------------------------------------------------
queueSync(state: SDKState): void {
this.pendingState = state
this.syncState.pendingChanges++
this.syncState.localVersion++
this.saveToLocalStorage(state)
this.broadcastState()
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
}
this.debounceTimeout = setTimeout(() => {
this.syncToServer(state)
}, this.options.debounceMs)
}
async forceSync(state: SDKState): Promise<void> {
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
this.debounceTimeout = null
}
await this.syncToServer(state)
}
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.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
this.broadcastSyncComplete(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<SDKState | null> {
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
// ---------------------------------------------------------------------------
private async handleConflict(localState: SDKState): Promise<void> {
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)
let resolvedState: SDKState
switch (resolution.strategy) {
case 'local':
resolvedState = localState
break
case 'server':
resolvedState = serverState
break
case 'merge':
resolvedState = resolution.mergedState || localState
break
}
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)
}
}
private async defaultConflictHandler(
local: SDKState,
server: SDKState
): Promise<ConflictResolution> {
const localTime = new Date(local.lastModified).getTime()
const serverTime = new Date(server.lastModified).getTime()
if (localTime > serverTime) {
return { strategy: 'local' }
}
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
}
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)
}