Files
breakpilot-compliance/breakpilot-compliance-sdk/packages/core/src/sync.ts
T
Sharang Parnerkar 9ecd3b2d84 refactor(sdk): split hooks, dsr-portal, provider, sync approaching 500 LOC
All four files split into focused sibling modules so every file lands
comfortably under the 300-LOC soft target (hard cap 500):

  hooks.ts (474→43)  → hooks-core / hooks-dsgvo / hooks-compliance
                        hooks-rag-security / hooks-ui
  dsr-portal.ts (464→129) → dsr-portal-translations / dsr-portal-render
  provider.tsx (462→247)  → provider-effects / provider-callbacks
  sync.ts (435→299)       → sync-storage / sync-conflict

Zero behaviour changes. All public APIs remain importable from the
original paths (hooks.ts re-exports every hook, provider.tsx keeps all
named exports, sync.ts preserves StateSyncManager + factory).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 08:40:20 +02:00

300 lines
8.2 KiB
TypeScript

// 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<ConflictResolution>
}
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<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 ?? 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<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
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<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 (sync-conflict helpers) --
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)
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)
}