/** * SDK API Client * * Centralized API client for SDK state management with error handling, * retry logic, and optimistic locking support. * * Domain methods are implemented in sibling files and delegated to here: * api-client-state.ts — getState, saveState, deleteState, exportState * api-client-projects.ts — listProjects … permanentlyDeleteProject * api-client-wiki.ts — listWikiCategories … searchWiki * api-client-operations.ts — checkpoints, flow, modules, UCCA, import, screening */ import { SDKState, CheckpointStatus, ProjectInfo, WikiCategory, WikiArticle, WikiSearchResult } from './types' import { APIResponse, StateResponse, SaveStateRequest, CheckpointValidationResult, APIError, FetchContext, DEFAULT_BASE_URL, DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_DELAYS, } from './api-client-types' // Re-export public types so existing consumers keep working export type { APIResponse, StateResponse, SaveStateRequest, CheckpointValidationResult, APIError } // Domain helpers import * as stateHelpers from './api-client-state' import * as projectHelpers from './api-client-projects' import * as wikiHelpers from './api-client-wiki' import * as opsHelpers from './api-client-operations' // ============================================================================= // API CLIENT // ============================================================================= export class SDKApiClient { private baseUrl: string private tenantId: string private projectId: string | undefined private timeout: number private abortControllers: Map = new Map() constructor(options: { baseUrl?: string tenantId: string projectId?: string timeout?: number }) { this.baseUrl = options.baseUrl || DEFAULT_BASE_URL this.tenantId = options.tenantId this.projectId = options.projectId this.timeout = options.timeout || DEFAULT_TIMEOUT } // --------------------------------------------------------------------------- // Private infrastructure — also exposed via FetchContext to helpers // --------------------------------------------------------------------------- createError(message: string, status?: number, retryable = false): APIError { const error = new Error(message) as APIError error.status = status error.retryable = retryable return error } async fetchWithTimeout( url: string, options: RequestInit, requestId: string ): Promise { const controller = new AbortController() this.abortControllers.set(requestId, controller) const timeoutId = setTimeout(() => controller.abort(), this.timeout) try { const response = await fetch(url, { ...options, signal: controller.signal, }) return response } finally { clearTimeout(timeoutId) this.abortControllers.delete(requestId) } } async fetchWithRetry( url: string, options: RequestInit, retries = MAX_RETRIES ): Promise { const requestId = `${Date.now()}-${Math.random()}` let lastError: Error | null = null for (let attempt = 0; attempt <= retries; attempt++) { try { const response = await this.fetchWithTimeout(url, options, requestId) if (!response.ok) { const errorBody = await response.text() let errorMessage = `HTTP ${response.status}` try { const errorJson = JSON.parse(errorBody) errorMessage = errorJson.error || errorJson.message || errorMessage } catch { // Keep the HTTP status message } // Don't retry client errors (4xx) except for 429 (rate limit) const retryable = response.status >= 500 || response.status === 429 if (!retryable || attempt === retries) { throw this.createError(errorMessage, response.status, retryable) } } else { const data = await response.json() return data as T } } catch (error) { lastError = error as Error if (error instanceof Error && error.name === 'AbortError') { throw this.createError('Request timeout', 408, true) } // Check if it's a retryable error const apiError = error as APIError if (!apiError.retryable || attempt === retries) { throw error } } // Wait before retrying (exponential backoff) if (attempt < retries) { await this.sleep(RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1]) } } throw lastError || this.createError('Unknown error', 500, false) } private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } /** Build a FetchContext for passing to domain helpers */ private get ctx(): FetchContext { return { baseUrl: this.baseUrl, tenantId: this.tenantId, projectId: this.projectId, fetchWithRetry: this.fetchWithRetry.bind(this), fetchWithTimeout: this.fetchWithTimeout.bind(this), createError: this.createError.bind(this), } } // --------------------------------------------------------------------------- // State Management (api-client-state.ts) // --------------------------------------------------------------------------- async getState(): Promise { return stateHelpers.getState(this.ctx) } async saveState(state: SDKState, version?: number): Promise { return stateHelpers.saveState(this.ctx, state, version) } async deleteState(): Promise { return stateHelpers.deleteState(this.ctx) } async exportState(format: 'json' | 'pdf' | 'zip'): Promise { return stateHelpers.exportState(this.ctx, format) } // --------------------------------------------------------------------------- // Checkpoints & Flow (api-client-operations.ts) // --------------------------------------------------------------------------- async validateCheckpoint(checkpointId: string, data?: unknown): Promise { return opsHelpers.validateCheckpoint(this.ctx, checkpointId, data) } async getCheckpoints(): Promise> { return opsHelpers.getCheckpoints(this.ctx) } async getFlowState() { return opsHelpers.getFlowState(this.ctx) } async navigateFlow(direction: 'next' | 'previous') { return opsHelpers.navigateFlow(this.ctx, direction) } // --------------------------------------------------------------------------- // Modules, UCCA, Import, Screening, Health (api-client-operations.ts) // --------------------------------------------------------------------------- async getModules(filters?: Parameters[1]) { return opsHelpers.getModules(this.ctx, filters) } async assessUseCase(intake: unknown) { return opsHelpers.assessUseCase(this.ctx, intake) } async getAssessments() { return opsHelpers.getAssessments(this.ctx) } async getAssessment(id: string) { return opsHelpers.getAssessment(this.ctx, id) } async deleteAssessment(id: string) { return opsHelpers.deleteAssessment(this.ctx, id) } async analyzeDocument(formData: FormData) { return opsHelpers.analyzeDocument(this.ctx, formData) } async scanDependencies(formData: FormData) { return opsHelpers.scanDependencies(this.ctx, formData) } async healthCheck() { return opsHelpers.healthCheck(this.ctx) } // --------------------------------------------------------------------------- // Projects (api-client-projects.ts) // --------------------------------------------------------------------------- async listProjects(includeArchived = true) { return projectHelpers.listProjects(this.ctx, includeArchived) } async createProject(data: Parameters[1]) { return projectHelpers.createProject(this.ctx, data) } async updateProject(projectId: string, data: Parameters[2]) { return projectHelpers.updateProject(this.ctx, projectId, data) } async getProject(projectId: string) { return projectHelpers.getProject(this.ctx, projectId) } async archiveProject(projectId: string) { return projectHelpers.archiveProject(this.ctx, projectId) } async restoreProject(projectId: string) { return projectHelpers.restoreProject(this.ctx, projectId) } async permanentlyDeleteProject(projectId: string) { return projectHelpers.permanentlyDeleteProject(this.ctx, projectId) } // --------------------------------------------------------------------------- // Wiki (api-client-wiki.ts) // --------------------------------------------------------------------------- async listWikiCategories() { return wikiHelpers.listWikiCategories(this.ctx) } async listWikiArticles(categoryId?: string) { return wikiHelpers.listWikiArticles(this.ctx, categoryId) } async getWikiArticle(id: string) { return wikiHelpers.getWikiArticle(this.ctx, id) } async searchWiki(query: string) { return wikiHelpers.searchWiki(this.ctx, query) } // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- cancelAllRequests(): void { this.abortControllers.forEach(controller => controller.abort()) this.abortControllers.clear() } setTenantId(tenantId: string): void { this.tenantId = tenantId } getTenantId(): string { return this.tenantId } setProjectId(projectId: string | undefined): void { this.projectId = projectId } getProjectId(): string | undefined { return this.projectId } } // ============================================================================= // SINGLETON FACTORY // ============================================================================= let clientInstance: SDKApiClient | null = null export function getSDKApiClient(tenantId?: string, projectId?: string): SDKApiClient { if (!clientInstance && !tenantId) { throw new Error('SDKApiClient not initialized. Provide tenantId on first call.') } if (!clientInstance && tenantId) { clientInstance = new SDKApiClient({ tenantId, projectId }) } if (tenantId && clientInstance && clientInstance.getTenantId() !== tenantId) { clientInstance.setTenantId(tenantId) } if (clientInstance) { clientInstance.setProjectId(projectId) } return clientInstance! } export function resetSDKApiClient(): void { if (clientInstance) { clientInstance.cancelAllRequests() } clientInstance = null }