@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
text/typescript
/**
* 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