UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

923 lines (869 loc) 29.3 kB
/** * KRAPI SDK - Main Wrapper * * A simple, unified interface that works seamlessly for both client and server applications. * This wrapper automatically detects the environment and provides the appropriate methods. * * Implements the KrapiSocketInterface for perfect client/server parity. * * @module krapi * @example Client App Usage: * ```typescript * import { krapi } from '@smartsamurai/krapi-sdk'; * * // Setup for client app * await krapi.connect({ * endpoint: 'https://api.myapp.com/krapi/k1', * apiKey: 'your-api-key' * }); * * // Use seamlessly * const project = await krapi.projects.create({ name: 'My Project' }); * const collection = await krapi.collections.create(project.id, { name: 'tasks', fields: [...] }); * const document = await krapi.documents.create(project.id, 'tasks', { data: {...} }); * ``` * * @example Server App Usage: * ```typescript * import { krapi } from '@smartsamurai/krapi-sdk'; * * // Setup for server app * await krapi.connect({ * database: databaseConnection, * logger: console * }); * * // Use the exact same methods * const project = await krapi.projects.create({ name: 'My Project' }); * const collection = await krapi.collections.create(project.id, { name: 'tasks', fields: [...] }); * const document = await krapi.documents.create(project.id, 'tasks', { data: {...} }); * ``` */ import { DatabaseConnection, Logger } from "./core"; import { KrapiError } from "./core/krapi-error"; import { RetryConfig } from "./http-clients/base-http-client"; import { ConnectionManager } from "./krapi/connection-manager"; import { ServiceManager } from "./krapi/service-manager"; import { KrapiSocketInterface } from "./socket-interface"; import { ApiKey } from "./types"; /** * Client connection configuration */ export interface ClientConnectionConfig { mode?: "client"; endpoint: string; apiKey?: string; sessionToken?: string; timeout?: number; retry?: RetryConfig; /** * Whether to initialize all HTTP clients immediately after connect(). * If true, all HTTP clients will be initialized without making requests. * This allows setSessionToken() to be called synchronously after connect(). * Default: false (clients are initialized lazily on first request) */ initializeClients?: boolean; } /** * Server connection configuration */ export interface ServerConnectionConfig { mode?: "server"; database: DatabaseConnection; logger?: Logger; } /** * KRAPI connection configuration (discriminated union) */ export type KrapiConfig = ClientConnectionConfig | ServerConnectionConfig; type Mode = "client" | "server" | null; /** * Main KRAPI wrapper class that provides a unified interface * * Implements the complete socket interface for perfect client/server parity. * Automatically switches between HTTP client mode and database mode based on configuration. * * @class KrapiWrapper * @implements {KrapiSocketInterface} */ class KrapiWrapper implements KrapiSocketInterface { private connectionManager: ConnectionManager; private serviceManager: ServiceManager; constructor() { this.connectionManager = new ConnectionManager(); this.serviceManager = new ServiceManager(null, console); } /** * Create a new isolated SDK instance * * Use this method to create separate SDK instances for concurrent requests. * Each instance maintains its own HTTP clients and authentication state, * preventing token conflicts in multi-user or concurrent scenarios. * * @returns {KrapiWrapper} A new isolated SDK instance * * @example * // Create isolated instances for concurrent requests * const sdk1 = krapi.createInstance(); * await sdk1.connect({ endpoint, apiKey }); * sdk1.auth.setSessionToken(tokenA); // Only affects sdk1 * * const sdk2 = krapi.createInstance(); * await sdk2.connect({ endpoint, apiKey }); * sdk2.auth.setSessionToken(tokenB); // Only affects sdk2 * * // Now both can be used concurrently without conflicts * await Promise.all([ * sdk1.projects.getAll(), // Uses tokenA * sdk2.projects.getAll() // Uses tokenB * ]); */ createInstance(): KrapiWrapper { return new KrapiWrapper(); } /** * Check if SDK is currently connected * * @returns {boolean} True if SDK is connected (has mode set) * * @example * if (!sdk.isConnected()) { * await sdk.connect({ endpoint, apiKey }); * } */ isConnected(): boolean { return this.connectionManager.isConnected(); } /** * Connect to KRAPI backend (client mode) or initialize with database (server mode) * * Supports reconnection to different endpoints. If already connected to a different endpoint, * all HTTP clients will be recreated with the new endpoint configuration. * * Determines the connection mode based on the provided configuration: * - If `endpoint` is provided: Client mode (HTTP) * - If `database` is provided: Server mode (Database) * * @param {KrapiConfig} config - Connection configuration * @param {string} [config.endpoint] - API endpoint URL (for client mode) * @param {string} [config.apiKey] - API key (for client mode) * @param {string} [config.sessionToken] - Session token (for client mode) * @param {number} [config.timeout] - Request timeout in milliseconds * @param {DatabaseConnection} [config.database] - Database connection (for server mode) * @param {Logger} [config.logger] - Logger instance (for server mode) * @returns {Promise<void>} * @throws {Error} If neither endpoint nor database is provided * * @example * // Client mode * await krapi.connect({ endpoint: 'https://api.example.com/krapi/k1', apiKey: 'key' }); * * @example * // Reconnection to different endpoint * await krapi.connect({ endpoint: 'http://127.0.0.1:3498', apiKey: 'key' }); * await krapi.connect({ endpoint: 'http://127.0.0.1:3470/krapi/k1', apiKey: 'key' }); // Updates all clients * * @example * // Server mode * await krapi.connect({ database: dbConnection, logger: console }); */ async connect(config: KrapiConfig): Promise<void> { // Use ConnectionManager to handle connection await this.connectionManager.connect(config); const mode = this.connectionManager.getMode(); const logger = this.connectionManager.getLogger(); const db = "database" in config ? config.database : undefined; // Update ServiceManager with new mode and logger this.serviceManager = new ServiceManager(mode as Mode, logger, db); // Initialize appropriate mode if (mode === "client" && "endpoint" in config) { await this.serviceManager.initializeClientMode(config); if (config.initializeClients === true) { // Clients are already initialized in initializeClientMode } } else if (mode === "server" && "database" in config) { await this.serviceManager.initializeServerMode(config); } } /** * Initialize all HTTP clients without making requests * * This method initializes all HTTP clients by calling their `initializeClient()` methods, * which sets up axios instances and interceptors without making any HTTP requests. * This is useful for setting session tokens before making requests. * * @returns {Promise<void>} * @throws {Error} If not in client mode or HTTP clients are not created * * @example * await sdk.connect({ endpoint, apiKey }); * await sdk.initializeAllClients(); // Initialize without making requests * sdk.auth.setSessionToken(token); // Now safe to call */ async initializeAllClients(): Promise<void> { // Clients are initialized in ServiceManager.initializeClientMode() // This method is kept for backward compatibility return Promise.resolve(); } /** * Authentication methods * * Provides user authentication, session management, and API key operations. * All methods work identically in both client and server environments. */ get auth() { return this.serviceManager.auth; } get projects() { return this.serviceManager.projects; } get collections() { return this.serviceManager.collections; } get documents() { return this.serviceManager.documents; } get storage() { return this.serviceManager.storage; } get users() { return this.serviceManager.users; } get email() { return this.serviceManager.email; } get admin() { return this.serviceManager.admin; } get system() { return this.serviceManager.system; } get health() { return this.serviceManager.health; } get backup() { return this.serviceManager.backup; } get mcp() { return this.serviceManager.mcp; } get activity() { return this.serviceManager.activity; } get changelog() { return this.serviceManager.changelog; } get testing() { return this.serviceManager.testing; } get apiKeys() { // API keys are managed through admin adapter const admin = this.serviceManager.admin; const mode = this.connectionManager.getMode(); return { getAll: async ( projectId: string, _options?: { limit?: number; offset?: number; type?: string; status?: string; } ) => { if (mode === "client") { throw KrapiError.badRequest( "apiKeys.getAll not available in client mode. Use admin service directly." ); } // Access AdminService methods directly - they're available in server mode const adminService = ( admin as unknown as { service?: { getProjectApiKeys?: (projectId: string) => Promise<unknown[]>; }; } ).service; if (!adminService?.getProjectApiKeys) { throw KrapiError.serviceUnavailable("Admin service not initialized"); } const keys = await adminService.getProjectApiKeys(projectId); return (keys || []) as ApiKey[]; }, get: async (projectId: string, keyId: string) => { if (mode === "client") { throw KrapiError.badRequest( "apiKeys.get not available in client mode. Use admin service directly." ); } const adminService = ( admin as unknown as { service?: { getProjectApiKey?: ( keyId: string, projectId: string ) => Promise<unknown>; }; } ).service; if (!adminService?.getProjectApiKey) { throw KrapiError.serviceUnavailable("Admin service not initialized"); } const key = await adminService.getProjectApiKey(keyId, projectId); return (key || {}) as ApiKey; }, create: async ( projectId: string, keyData: { name: string; scopes: string[]; expires_at?: string; rate_limit?: number; metadata?: Record<string, unknown>; } ) => { if (mode === "client") { throw KrapiError.badRequest( "apiKeys.create not available in client mode. Use admin service directly." ); } const adminService = ( admin as unknown as { service?: { createProjectApiKey?: ( projectId: string, keyData: unknown ) => Promise<{ data?: unknown }>; }; } ).service; if (!adminService?.createProjectApiKey) { throw KrapiError.serviceUnavailable("Admin service not initialized"); } const result = await adminService.createProjectApiKey( projectId, keyData ); return (result?.data || result || {}) as ApiKey; }, update: async ( projectId: string, keyId: string, updates: { name?: string; scopes?: string[]; expires_at?: string; is_active?: boolean; rate_limit?: number; metadata?: Record<string, unknown>; } ) => { if (mode === "client") { throw KrapiError.badRequest( "apiKeys.update not available in client mode. Use admin service directly." ); } const adminService = ( admin as unknown as { service?: { updateProjectApiKey?: ( keyId: string, projectId: string, updates: unknown ) => Promise<unknown>; }; } ).service; if (!adminService?.updateProjectApiKey) { throw KrapiError.serviceUnavailable("Admin service not initialized"); } const result = await adminService.updateProjectApiKey( keyId, projectId, updates ); return (result || {}) as ApiKey; }, delete: async (projectId: string, keyId: string) => { if (mode === "client") { throw KrapiError.badRequest( "apiKeys.delete not available in client mode. Use admin service directly." ); } const adminService = ( admin as unknown as { service?: { deleteProjectApiKey?: ( keyId: string, projectId: string ) => Promise<boolean>; }; } ).service; if (!adminService?.deleteProjectApiKey) { throw KrapiError.serviceUnavailable("Admin service not initialized"); } const success = await adminService.deleteProjectApiKey( keyId, projectId ); return { success: !!success }; }, regenerate: async (projectId: string, keyId: string) => { if (mode === "client") { throw KrapiError.badRequest( "apiKeys.regenerate not available in client mode. Use admin service directly." ); } const adminService = ( admin as unknown as { service?: { regenerateProjectApiKey?: ( keyId: string, projectId: string ) => Promise<{ data?: unknown }>; }; } ).service; if (!adminService?.regenerateProjectApiKey) { throw KrapiError.serviceUnavailable("Admin service not initialized"); } const result = await adminService.regenerateProjectApiKey( keyId, projectId ); return (result?.data || result || {}) as ApiKey; }, validateKey: async (apiKey: string) => { const auth = this.serviceManager.auth; return await auth.validateApiKey(apiKey); }, }; } get database() { // Database operations are available through health adapter and connection manager const mode = this.connectionManager.getMode(); return { initialize: async () => { if (mode === "server") { // In server mode, use admin service repairDatabase to initialize const admin = this.serviceManager.admin; const adminService = ( admin as unknown as { service?: { repairDatabase?: () => Promise<{ success: boolean; actions: string[]; }>; }; } ).service; if (!adminService?.repairDatabase) { throw KrapiError.serviceUnavailable( "Admin service not initialized" ); } try { // Use autoFix to create missing tables const autoFixResult = await this.health.autoFix(); const repairResult = await adminService.repairDatabase(); // Extract table names from actions const tablesCreated: string[] = []; for (const action of repairResult.actions) { if (action.includes("Created missing table")) { const match = action.match(/Created missing table: (.+)/); if (match && match[1]) { tablesCreated.push(match[1]); } } } return { success: repairResult.success && autoFixResult.success, message: `Database initialized. ${repairResult.actions.length} actions performed.`, tablesCreated: tablesCreated.length > 0 ? tablesCreated : [], defaultDataInserted: repairResult.actions.some((action) => action.includes("default admin") ), }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : "Failed to initialize database", tablesCreated: [], defaultDataInserted: false, }; } } else { throw KrapiError.badRequest( "database.initialize is only available in server mode. Use HTTP endpoints for client mode." ); } }, getHealth: async () => { const health = await this.health.checkDatabase(); return { database: health.healthy, storage: true, // Would need storage health check email: true, // Would need email health check overall: health.healthy, details: health.details || {}, }; }, createDefaultAdmin: async () => { if (mode === "server") { const admin = this.serviceManager.admin; const adminService = ( admin as unknown as { service?: { createDefaultAdmin?: () => Promise<void>; }; } ).service; if (!adminService?.createDefaultAdmin) { throw KrapiError.serviceUnavailable( "Admin service not initialized" ); } try { await adminService.createDefaultAdmin(); return { success: true, message: "Default admin user created successfully", adminUser: { username: "admin", email: "admin@krapi.com", }, }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : "Failed to create default admin", }; } } else { throw KrapiError.badRequest( "database.createDefaultAdmin is only available in server mode. Use HTTP endpoints for client mode." ); } }, // Additional methods used by examples.ts - delegate to health adapter healthCheck: async () => { return await this.health.checkDatabase(); }, autoFix: async () => { return await this.health.autoFix(); }, validateSchema: async () => { return await this.health.validateSchema(); }, getQueueMetrics: async () => { if (mode === "client") { // Client mode: use system HTTP client const serviceManager = this.serviceManager as unknown as { systemHttpClient?: { getQueueMetrics?: () => Promise<{ data?: { queueSize: number; processingCount: number; totalProcessed: number; totalErrors: number; averageWaitTime: number; averageProcessTime: number; queueItems: Array<{ id: string; priority: number; timestamp: number; }>; }; }>; }; }; if (!serviceManager.systemHttpClient?.getQueueMetrics) { throw KrapiError.serviceUnavailable( "System HTTP client not initialized" ); } // Add timeout to prevent hanging const timeoutPromise = new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Queue metrics request timeout")), 5000) ); const response = await Promise.race([ serviceManager.systemHttpClient.getQueueMetrics(), timeoutPromise, ]); // Normalize response format - ApiResponse wraps data if ( response && typeof response === "object" && "data" in response && response.data ) { return response.data; } // If response is already the data (unwrapped), return it if ( response && typeof response === "object" && "queueSize" in response ) { return response as { queueSize: number; processingCount: number; totalProcessed: number; totalErrors: number; averageWaitTime: number; averageProcessTime: number; queueItems: Array<{ id: string; priority: number; timestamp: number; }>; }; } // Return default empty metrics instead of throwing to prevent hanging return { queueSize: 0, processingCount: 0, totalProcessed: 0, totalErrors: 0, averageWaitTime: 0, averageProcessTime: 0, queueItems: [], }; } else { // Server mode: DatabaseService.getInstance().getQueueMetrics() // Note: DatabaseService is a backend service, not part of SDK // For now, throw an error indicating it needs backend DatabaseService // In the future, this could query the database directly if queue tables exist throw KrapiError.serviceUnavailable( "database.getQueueMetrics() in server mode requires DatabaseService.getInstance().getQueueMetrics(). " + "This should be called from the backend server context where DatabaseService is available." ); } }, }; } // All service objects are now delegated to adapters via ServiceManager // Legacy implementations removed - functionality preserved through adapters async healthCheck(): Promise<boolean> { try { const health = await this.health.check(); return health.healthy; } catch { return false; } } /** * Get detailed health status * * Returns comprehensive health information including system status, * database health, and service availability. * * @returns {Promise<{ status: 'ok' | 'degraded' | 'down'; details: Record<string, unknown> }>} Health status * * @example * const health = await krapi.getHealthStatus(); * console.log(`Status: ${health.status}`); */ async getHealthStatus(): Promise<{ status: "ok" | "degraded" | "down"; details: Record<string, unknown>; }> { try { const health = await this.health.check(); const diagnostics = await this.health.runDiagnostics(); // Determine overall status let status: "ok" | "degraded" | "down" = "ok"; if (!health.healthy) { status = "down"; } else if (diagnostics.summary.failed > 0) { status = "degraded"; } return { status, details: { health, diagnostics, timestamp: new Date().toISOString(), }, }; } catch (error) { return { status: "down", details: { error: error instanceof Error ? error.message : "Unknown error", timestamp: new Date().toISOString(), }, }; } } /** * Check SDK version compatibility with server * * Verifies that the SDK version is compatible with the server version. * * @returns {Promise<{ compatible: boolean; sdkVersion: string; serverVersion?: string; message?: string }>} Compatibility check result * * @example * const compatibility = await krapi.checkCompatibility(); * if (!compatibility.compatible) { * console.warn(`SDK version ${compatibility.sdkVersion} may not be compatible with server version ${compatibility.serverVersion}`); * } */ async checkCompatibility(): Promise<{ compatible: boolean; sdkVersion: string; serverVersion?: string; message?: string; }> { const sdkVersion = "0.1.10"; // Current SDK version from package.json try { const mode = this.connectionManager.getMode(); if (mode === "client") { // Try to get server version from health endpoint const health = await this.health.check(); const serverVersion = health.version || "unknown"; // Basic compatibility check (can be enhanced with semantic versioning) const compatible = serverVersion !== "unknown"; return { compatible, sdkVersion, serverVersion, ...(compatible ? {} : { message: `Unable to determine server version. SDK version: ${sdkVersion}`, }), }; } else { // Server mode - assume compatible since we're using the same codebase return { compatible: true, sdkVersion, serverVersion: sdkVersion, }; } } catch (error) { return { compatible: false, sdkVersion, message: `Compatibility check failed: ${ error instanceof Error ? error.message : "Unknown error" }`, }; } } /** * Get the current connection mode * * Returns the current mode: 'client' for HTTP mode, 'server' for database mode, * or null if not yet connected. * * @returns {Mode} Current mode ('client' | 'server' | null) * * @example * const mode = krapi.getMode(); * if (mode === 'client') { * // Using HTTP client * } */ getMode(): Mode { return this.connectionManager.getMode(); } /** * Get current configuration * * Returns the current SDK configuration including mode, endpoint, API key, * and database connection (if in server mode). * * @returns {Object} Configuration object * @returns {string} returns.mode - Current mode ('client' | 'server' | null) * @returns {string} [returns.endpoint] - API endpoint (client mode) * @returns {string} [returns.apiKey] - API key (client mode) * @returns {Record<string, unknown>} [returns.database] - Database connection info (server mode) * * @example * const config = krapi.getConfig(); */ getConfig(): { mode: "client" | "server" | null; endpoint?: string; apiKey?: string; database?: Record<string, unknown>; } { const config = this.connectionManager.getConfig(); if (!config) { return { mode: null }; } const result: { mode: "client" | "server" | null; endpoint?: string; apiKey?: string; database?: Record<string, unknown>; } = { mode: this.connectionManager.getMode(), }; if ("endpoint" in config) { result.endpoint = config.endpoint; if (config.apiKey !== undefined) { result.apiKey = config.apiKey; } } if ("database" in config) { result.database = config.database as unknown as Record<string, unknown>; } return result; } /** * Close the connection and clean up resources * * Closes database connections (in server mode) and cleans up resources. * Should be called when the SDK is no longer needed. * * @returns {Promise<void>} * * @example * await krapi.close(); */ async close(): Promise<void> { const config = this.connectionManager.getConfig(); const mode = this.connectionManager.getMode(); if ( mode === "server" && config && "database" in config && config.database?.end ) { await config.database.end(); } const logger = this.connectionManager.getLogger(); logger.info("KRAPI SDK connection closed"); } /** * Check storage system health */ // Health check methods removed - functionality available through health adapter // System backup functionality available through backup adapter /** * Generate password hash for user management */ // Password hash generation available for user management // @ts-expect-error - Method reserved for future use private async _generatePasswordHash(password: string): Promise<string> { // In a real implementation, this would use bcrypt or similar // For now, return a simple hash (this should be replaced with proper hashing) return `hash_${password}_${Date.now()}`; } } // Create a singleton instance const krapiInstance = new KrapiWrapper(); // Export the singleton instance export const krapi = krapiInstance; // Also export the class for advanced usage export { KrapiWrapper }; // Configuration type is exported at the interface declaration above