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>
This commit is contained in:
67
breakpilot-compliance-sdk/packages/core/src/sync-conflict.ts
Normal file
67
breakpilot-compliance-sdk/packages/core/src/sync-conflict.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Conflict resolution helpers for StateSyncManager.
|
||||
*
|
||||
* Extracted from sync.ts to stay within the 300-LOC target.
|
||||
*/
|
||||
|
||||
import type { SDKState, ConflictResolution } from '@breakpilot/compliance-sdk-types'
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULT CONFLICT HANDLER
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Default strategy: if local is newer, keep local; otherwise merge
|
||||
* server as the base but preserve local preferences and deduplicate
|
||||
* commandBarHistory / recentSearches.
|
||||
*/
|
||||
export async function 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 }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONFLICT RESOLUTION APPLIER
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Given a resolution strategy and both states, returns the winning state.
|
||||
*/
|
||||
export function applyConflictResolution(
|
||||
resolution: ConflictResolution,
|
||||
localState: SDKState,
|
||||
serverState: SDKState
|
||||
): SDKState {
|
||||
switch (resolution.strategy) {
|
||||
case 'local':
|
||||
return localState
|
||||
case 'server':
|
||||
return serverState
|
||||
case 'merge':
|
||||
return resolution.mergedState || localState
|
||||
}
|
||||
}
|
||||
154
breakpilot-compliance-sdk/packages/core/src/sync-storage.ts
Normal file
154
breakpilot-compliance-sdk/packages/core/src/sync-storage.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Local-storage and BroadcastChannel helpers for StateSyncManager.
|
||||
*
|
||||
* Extracted from sync.ts to stay within the 300-LOC target.
|
||||
*/
|
||||
|
||||
import type { SDKState, SyncState } from '@breakpilot/compliance-sdk-types'
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const STORAGE_KEY_PREFIX = 'breakpilot-compliance-sdk-state'
|
||||
export const SYNC_CHANNEL = 'breakpilot-sdk-state-sync'
|
||||
|
||||
// =============================================================================
|
||||
// TAB ID
|
||||
// =============================================================================
|
||||
|
||||
export function 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 HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export function getStorageKey(tenantId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}-${tenantId}`
|
||||
}
|
||||
|
||||
export function saveStateToLocalStorage(
|
||||
tenantId: string,
|
||||
state: SDKState,
|
||||
version: number
|
||||
): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
const data = {
|
||||
state,
|
||||
version,
|
||||
savedAt: new Date().toISOString(),
|
||||
}
|
||||
localStorage.setItem(getStorageKey(tenantId), JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStateFromLocalStorage(
|
||||
tenantId: string,
|
||||
syncState: SyncState
|
||||
): SDKState | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(getStorageKey(tenantId))
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored)
|
||||
syncState.localVersion = data.version || 0
|
||||
return data.state
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load from localStorage:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function clearStateFromLocalStorage(tenantId: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(getStorageKey(tenantId))
|
||||
} catch (error) {
|
||||
console.error('Failed to clear localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BROADCAST CHANNEL HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export function createBroadcastChannel(
|
||||
tenantId: string
|
||||
): BroadcastChannel | null {
|
||||
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new BroadcastChannel(`${SYNC_CHANNEL}-${tenantId}`)
|
||||
} catch (error) {
|
||||
console.warn('BroadcastChannel not available:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastStateUpdate(
|
||||
channel: BroadcastChannel | null,
|
||||
state: SDKState,
|
||||
version: number
|
||||
): void {
|
||||
if (!channel) return
|
||||
|
||||
channel.postMessage({
|
||||
type: 'STATE_UPDATED',
|
||||
state,
|
||||
version,
|
||||
tabId: getTabId(),
|
||||
})
|
||||
}
|
||||
|
||||
export function broadcastSyncComplete(
|
||||
channel: BroadcastChannel | null,
|
||||
version: number
|
||||
): void {
|
||||
if (!channel) return
|
||||
|
||||
channel.postMessage({
|
||||
type: 'SYNC_COMPLETE',
|
||||
version,
|
||||
tabId: getTabId(),
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ONLINE / OFFLINE LISTENER
|
||||
// =============================================================================
|
||||
|
||||
export interface OnlineListenerHandlers {
|
||||
onOnline: () => void
|
||||
onOffline: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers window online/offline listeners and returns the current online status.
|
||||
* No-ops in non-browser environments.
|
||||
*/
|
||||
export function setupOnlineListener(handlers: OnlineListenerHandlers): boolean {
|
||||
if (typeof window === 'undefined') return true
|
||||
|
||||
window.addEventListener('online', handlers.onOnline)
|
||||
window.addEventListener('offline', handlers.onOffline)
|
||||
|
||||
return navigator.onLine
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
/**
|
||||
* SDK State Synchronization
|
||||
*
|
||||
* Handles offline/online sync, multi-tab coordination,
|
||||
* and conflict resolution for SDK state.
|
||||
*/
|
||||
// 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
|
||||
// =============================================================================
|
||||
// --- 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
|
||||
@@ -27,18 +30,10 @@ export interface SyncCallbacks {
|
||||
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
|
||||
// =============================================================================
|
||||
// --- StateSyncManager --------------------------------------------------------
|
||||
|
||||
export class StateSyncManager {
|
||||
private client: ComplianceClient
|
||||
@@ -63,7 +58,7 @@ export class StateSyncManager {
|
||||
this.options = {
|
||||
debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
||||
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
|
||||
conflictHandler: options.conflictHandler ?? this.defaultConflictHandler.bind(this),
|
||||
conflictHandler: options.conflictHandler ?? defaultConflictHandler,
|
||||
}
|
||||
|
||||
this.syncState = {
|
||||
@@ -79,52 +74,33 @@ export class StateSyncManager {
|
||||
this.setupOnlineListener()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Setup --
|
||||
|
||||
private setupBroadcastChannel(): void {
|
||||
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.broadcastChannel = new BroadcastChannel(`${SYNC_CHANNEL}-${this.tenantId}`)
|
||||
this.broadcastChannel = createBroadcastChannel(this.tenantId)
|
||||
if (this.broadcastChannel) {
|
||||
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)
|
||||
}
|
||||
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?.()
|
||||
},
|
||||
})
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false
|
||||
this.syncState.status = 'offline'
|
||||
this.callbacks.onOffline?.()
|
||||
})
|
||||
|
||||
this.isOnline = navigator.onLine
|
||||
if (!this.isOnline) {
|
||||
this.syncState.status = 'offline'
|
||||
}
|
||||
if (!this.isOnline) this.syncState.status = 'offline'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Broadcast Channel Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Broadcast Channel --
|
||||
|
||||
private handleBroadcastMessage(event: MessageEvent): void {
|
||||
const { type, state, version } = event.data
|
||||
@@ -143,98 +119,33 @@ export class StateSyncManager {
|
||||
break
|
||||
|
||||
case 'REQUEST_STATE':
|
||||
this.broadcastState()
|
||||
this.broadcastCurrentState()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastState(): void {
|
||||
if (!this.broadcastChannel) return
|
||||
|
||||
private broadcastCurrentState(): void {
|
||||
const state = this.loadFromLocalStorage()
|
||||
if (state) {
|
||||
this.broadcastChannel.postMessage({
|
||||
type: 'STATE_UPDATED',
|
||||
state,
|
||||
version: this.syncState.localVersion,
|
||||
tabId: this.getTabId(),
|
||||
})
|
||||
broadcastStateUpdate(this.broadcastChannel, state, this.syncState.localVersion)
|
||||
}
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
// -- Local Storage (sync-storage helpers) --
|
||||
|
||||
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)
|
||||
}
|
||||
saveStateToLocalStorage(this.tenantId, state, this.syncState.localVersion)
|
||||
}
|
||||
|
||||
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
|
||||
return loadStateFromLocalStorage(this.tenantId, this.syncState)
|
||||
}
|
||||
|
||||
clearLocalStorage(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(this.getStorageKey())
|
||||
} catch (error) {
|
||||
console.error('Failed to clear localStorage:', error)
|
||||
}
|
||||
clearStateFromLocalStorage(this.tenantId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Sync --
|
||||
|
||||
queueSync(state: SDKState): void {
|
||||
this.pendingState = state
|
||||
@@ -242,7 +153,7 @@ export class StateSyncManager {
|
||||
|
||||
this.syncState.localVersion++
|
||||
this.saveToLocalStorage(state)
|
||||
this.broadcastState()
|
||||
broadcastStateUpdate(this.broadcastChannel, state, this.syncState.localVersion)
|
||||
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout)
|
||||
@@ -284,7 +195,7 @@ export class StateSyncManager {
|
||||
}
|
||||
|
||||
this.pendingState = null
|
||||
this.broadcastSyncComplete(response.version)
|
||||
broadcastSyncComplete(this.broadcastChannel, response.version)
|
||||
this.callbacks.onSyncComplete?.(state)
|
||||
} catch (error) {
|
||||
if ((error as { status?: number }).status === 409) {
|
||||
@@ -319,9 +230,7 @@ export class StateSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conflict Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Conflict Resolution (sync-conflict helpers) --
|
||||
|
||||
private async handleConflict(localState: SDKState): Promise<void> {
|
||||
this.syncState.status = 'conflict'
|
||||
@@ -338,19 +247,7 @@ export class StateSyncManager {
|
||||
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 resolvedState = applyConflictResolution(resolution, localState, serverState)
|
||||
|
||||
const response = await this.client.saveState(resolvedState)
|
||||
this.syncState.serverVersion = response.version
|
||||
@@ -365,38 +262,7 @@ export class StateSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Getters & Cleanup --
|
||||
|
||||
getSyncState(): SyncState {
|
||||
return { ...this.syncState }
|
||||
@@ -421,9 +287,7 @@ export class StateSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FACTORY
|
||||
// =============================================================================
|
||||
// --- Factory -----------------------------------------------------------------
|
||||
|
||||
export function createStateSyncManager(
|
||||
client: ComplianceClient,
|
||||
|
||||
Reference in New Issue
Block a user