9ecd3b2d84
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>
300 lines
8.2 KiB
TypeScript
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)
|
|
}
|