appwrite-utils-cli
Version:
Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.
530 lines (462 loc) • 18 kB
text/typescript
/**
* AdapterFactory - Unified Client Creation with Automatic API Detection
*
* This factory creates the appropriate database adapter (TablesDB or Legacy)
* based on version detection and configuration. It handles dynamic SDK imports
* and provides a single entry point for all database operations.
*/
import type { AppwriteConfig } from "appwrite-utils";
import { detectAppwriteVersionCached, isVersionAtLeast, type ApiMode, type VersionDetectionResult } from "../utils/versionDetection.js";
import { AdapterError, type DatabaseAdapter } from './DatabaseAdapter.js';
import { TablesDBAdapter } from './TablesDBAdapter.js';
import { LegacyAdapter } from './LegacyAdapter.js';
import { logger } from '../shared/logging.js';
import { isValidSessionCookie } from '../utils/sessionAuth.js';
import { MessageFormatter } from '../shared/messageFormatter.js';
import { Client } from 'node-appwrite';
export interface AdapterFactoryConfig {
appwriteEndpoint: string;
appwriteProject: string;
appwriteKey?: string; // Made optional to support session-only auth
apiMode?: 'auto' | 'legacy' | 'tablesdb';
forceRefresh?: boolean; // Skip detection cache
sessionCookie?: string; // Session authentication support
authMethod?: 'session' | 'apikey' | 'auto'; // Authentication method preference
preConfiguredClient?: any; // Pre-configured authenticated client
}
export interface AdapterFactoryResult {
adapter: DatabaseAdapter;
apiMode: ApiMode;
detectionResult?: VersionDetectionResult;
client: any;
}
/**
* AdapterFactory - Main factory class for creating database adapters
*/
export class AdapterFactory {
private static cache = new Map<string, { adapter: DatabaseAdapter; timestamp: number }>();
private static readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
/**
* Create a database adapter based on configuration and detection
*/
static async create(config: AdapterFactoryConfig): Promise<AdapterFactoryResult> {
const startTime = Date.now();
// Validate authentication configuration
this.validateAuthConfig(config);
const cacheKey = `${config.appwriteEndpoint}:${config.appwriteProject}:${config.apiMode || 'auto'}`;
logger.info('Creating database adapter', {
endpoint: config.appwriteEndpoint,
project: config.appwriteProject,
requestedApiMode: config.apiMode || 'auto',
authMethod: config.authMethod || 'auto',
hasSessionCookie: !!config.sessionCookie,
hasPreConfiguredClient: !!config.preConfiguredClient,
forceRefresh: config.forceRefresh,
cacheKey,
operation: 'AdapterFactory.create'
});
// Check cache first (unless force refresh)
if (!config.forceRefresh) {
const cached = this.getCachedAdapter(cacheKey);
if (cached) {
const cacheAge = Date.now() - cached.timestamp;
logger.info('Using cached adapter', {
cacheKey,
cacheAge,
apiMode: cached.adapter.getApiMode(),
operation: 'AdapterFactory.create'
});
return {
adapter: cached.adapter,
apiMode: cached.adapter.getApiMode(),
client: cached.adapter.getRawClient()
};
}
}
// Determine API mode
let apiMode: ApiMode;
let detectionResult: VersionDetectionResult | undefined;
if (config.apiMode && config.apiMode !== 'auto') {
// Use explicitly configured mode
apiMode = config.apiMode;
logger.info('Using explicitly configured API mode', {
apiMode,
endpoint: config.appwriteEndpoint,
operation: 'AdapterFactory.create'
});
} else {
// Auto-detect API mode
logger.info('Starting API mode auto-detection', {
endpoint: config.appwriteEndpoint,
project: config.appwriteProject,
forceRefresh: config.forceRefresh,
operation: 'AdapterFactory.create'
});
const detectionStartTime = Date.now();
// For version detection, we need some form of authentication
const authKey = config.appwriteKey || '';
detectionResult = await detectAppwriteVersionCached(
config.appwriteEndpoint,
config.appwriteProject,
authKey,
config.forceRefresh
);
const detectionDuration = Date.now() - detectionStartTime;
apiMode = detectionResult.apiMode;
logger.info('API mode detection completed', {
apiMode,
detectionMethod: detectionResult.detectionMethod,
confidence: detectionResult.confidence,
serverVersion: detectionResult.serverVersion,
detectionDuration,
endpoint: config.appwriteEndpoint,
operation: 'AdapterFactory.create'
});
// Add version-based safety check to prevent using TablesDB on old servers
if (detectionResult.serverVersion &&
!isVersionAtLeast(detectionResult.serverVersion, '1.8.0') &&
apiMode === 'tablesdb') {
logger.warn('Overriding TablesDB detection - server version too old', {
serverVersion: detectionResult.serverVersion,
detectedMode: apiMode,
overrideMode: 'legacy',
operation: 'AdapterFactory.create'
});
apiMode = 'legacy';
}
}
// Create appropriate adapter
logger.info('Creating adapter instance', {
apiMode,
endpoint: config.appwriteEndpoint,
operation: 'AdapterFactory.create'
});
const adapterStartTime = Date.now();
const result = await this.createAdapter(config, apiMode);
const adapterDuration = Date.now() - adapterStartTime;
// Cache the result
this.setCachedAdapter(cacheKey, result.adapter);
const totalDuration = Date.now() - startTime;
logger.info('Adapter creation completed', {
apiMode,
adapterDuration,
totalDuration,
cached: true,
operation: 'AdapterFactory.create'
});
return {
...result,
apiMode,
detectionResult
};
}
/**
* Create adapter from AppwriteConfig (convenience method)
*/
static async createFromConfig(config: AppwriteConfig, forceRefresh?: boolean): Promise<AdapterFactoryResult> {
return this.create({
appwriteEndpoint: config.appwriteEndpoint,
appwriteProject: config.appwriteProject,
appwriteKey: config.appwriteKey,
apiMode: (config as any).apiMode || 'auto', // Cast to access new property
forceRefresh
});
}
/**
* Create specific adapter type (internal method)
*/
private static async createAdapter(
config: AdapterFactoryConfig,
apiMode: ApiMode
): Promise<{ adapter: DatabaseAdapter; client: any }> {
if (apiMode === 'tablesdb') {
return this.createTablesDBAdapter(config);
} else {
return this.createLegacyAdapter(config);
}
}
/**
* Create TablesDB adapter with dynamic import
*/
private static async createTablesDBAdapter(
config: AdapterFactoryConfig
): Promise<{ adapter: DatabaseAdapter; client: any }> {
const startTime = Date.now();
try {
logger.info('Creating TablesDB adapter (static SDK imports)', {
endpoint: config.appwriteEndpoint,
operation: 'createTablesDBAdapter'
});
let client = new Client()
.setEndpoint(config.appwriteEndpoint)
.setProject(config.appwriteProject);
// Set authentication method with mode headers
// Prefer session with admin mode, fallback to API key with default mode
if (config.sessionCookie && isValidSessionCookie(config.sessionCookie)) {
client.setSession(config.sessionCookie);
client.headers['X-Appwrite-Mode'] = 'admin';
logger.debug('Using session authentication for TablesDB adapter', {
project: config.appwriteProject,
operation: 'createTablesDBAdapter'
});
} else if (config.appwriteKey) {
client.setKey(config.appwriteKey);
client.headers['X-Appwrite-Mode'] = 'default';
logger.debug('Using API key authentication for TablesDB adapter', {
project: config.appwriteProject,
operation: 'createTablesDBAdapter'
});
} else {
throw new Error("No authentication available for adapter");
}
const adapter = new TablesDBAdapter(client);
const totalDuration = Date.now() - startTime;
logger.info('TablesDB adapter created successfully', {
totalDuration,
endpoint: config.appwriteEndpoint,
operation: 'createTablesDBAdapter'
});
return { adapter, client };
} catch (error) {
const errorDuration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Analyze the error to determine if fallback is appropriate
const isAuthError = errorMessage.toLowerCase().includes('unauthorized') ||
errorMessage.toLowerCase().includes('forbidden') ||
errorMessage.toLowerCase().includes('invalid') ||
errorMessage.toLowerCase().includes('authentication');
const isVersionError = errorMessage.toLowerCase().includes('not found') ||
errorMessage.toLowerCase().includes('unsupported') ||
errorMessage.toLowerCase().includes('tablesdb');
// Only fallback to legacy if this is genuinely a TablesDB support issue
if (isVersionError) {
MessageFormatter.warning('TablesDB not supported on this server - using legacy adapter', { prefix: "Adapter" });
logger.warn('TablesDB not supported, falling back to legacy', {
error: errorMessage,
errorDuration,
endpoint: config.appwriteEndpoint,
operation: 'createTablesDBAdapter'
});
return this.createLegacyAdapter(config);
} else {
// For auth or other errors, re-throw to surface the real problem
logger.error('TablesDB adapter creation failed with non-version error', {
error: errorMessage,
errorDuration,
endpoint: config.appwriteEndpoint,
operation: 'createTablesDBAdapter',
isAuthError
});
throw new AdapterError(
`TablesDB adapter creation failed: ${errorMessage}`,
'TABLESDB_ADAPTER_CREATION_FAILED',
error instanceof Error ? error : undefined
);
}
}
}
/**
* Create Legacy adapter with dynamic import
*/
private static async createLegacyAdapter(
config: AdapterFactoryConfig
): Promise<{ adapter: DatabaseAdapter; client: any }> {
const startTime = Date.now();
try {
logger.info('Creating legacy adapter (static SDK imports)', {
endpoint: config.appwriteEndpoint,
operation: 'createLegacyAdapter'
});
// Use pre-configured client or create session-aware client with Legacy Client
let client: any;
if (config.preConfiguredClient) {
client = config.preConfiguredClient;
} else {
client = new Client()
.setEndpoint(config.appwriteEndpoint)
.setProject(config.appwriteProject);
// Set authentication method with mode headers
// Prefer session with admin mode, fallback to API key with default mode
if (config.sessionCookie && isValidSessionCookie(config.sessionCookie)) {
(client as any).setSession(config.sessionCookie);
client.headers['X-Appwrite-Mode'] = 'admin';
logger.debug('Using session authentication for Legacy adapter', {
project: config.appwriteProject,
operation: 'createLegacyAdapter'
});
} else if (config.appwriteKey) {
client.setKey(config.appwriteKey);
client.headers['X-Appwrite-Mode'] = 'default';
logger.debug('Using API key authentication for Legacy adapter', {
project: config.appwriteProject,
operation: 'createLegacyAdapter'
});
} else {
throw new Error("No authentication available for adapter");
}
}
const adapter = new LegacyAdapter(client);
const totalDuration = Date.now() - startTime;
logger.info('Legacy adapter created successfully', {
totalDuration,
endpoint: config.appwriteEndpoint,
operation: 'createLegacyAdapter'
});
return { adapter, client };
} catch (error) {
const errorDuration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to load legacy Appwrite SDK', {
error: errorMessage,
errorDuration,
endpoint: config.appwriteEndpoint,
operation: 'createLegacyAdapter'
});
throw new Error(`Failed to load legacy Appwrite SDK: ${errorMessage}`);
}
}
/**
* Get cached adapter if available and not expired
*/
private static getCachedAdapter(cacheKey: string): { adapter: DatabaseAdapter; timestamp: number } | null {
const cached = this.cache.get(cacheKey);
if (!cached) {
return null;
}
// Check if cache is expired
if (Date.now() - cached.timestamp > this.CACHE_DURATION) {
this.cache.delete(cacheKey);
return null;
}
return cached;
}
/**
* Cache adapter instance
*/
private static setCachedAdapter(cacheKey: string, adapter: DatabaseAdapter): void {
this.cache.set(cacheKey, {
adapter,
timestamp: Date.now()
});
}
/**
* Clear adapter cache (useful for testing)
*/
static clearCache(): void {
this.cache.clear();
}
/**
* Validate authentication configuration
*/
private static validateAuthConfig(config: AdapterFactoryConfig): void {
const hasApiKey = config.appwriteKey && config.appwriteKey.trim().length > 0;
const hasSessionCookie = config.sessionCookie && isValidSessionCookie(config.sessionCookie);
const hasPreConfiguredClient = !!config.preConfiguredClient;
// Must have at least one authentication method
if (!hasApiKey && !hasSessionCookie && !hasPreConfiguredClient) {
throw new Error(
`No valid authentication method provided for project ${config.appwriteProject}. ` +
`Please provide an API key, session cookie, or pre-configured client.`
);
}
// Validate session cookie format if provided
if (config.sessionCookie && !isValidSessionCookie(config.sessionCookie)) {
throw new Error(
`Invalid session cookie format provided for project ${config.appwriteProject}. ` +
`Session cookie must be a valid JWT token.`
);
}
logger.debug('Authentication configuration validated', {
hasApiKey,
hasSessionCookie,
hasPreConfiguredClient,
authMethod: config.authMethod || 'auto',
operation: 'validateAuthConfig'
});
}
/**
* Test connection and API capabilities
*/
static async testConnection(config: AdapterFactoryConfig): Promise<{
success: boolean;
apiMode: ApiMode;
capabilities: string[];
error?: string;
}> {
try {
const result = await this.create({ ...config, forceRefresh: true });
const metadata = result.adapter.getMetadata();
// Test basic operations
const capabilities = [];
if (metadata.capabilities.bulkOperations) {
capabilities.push('Bulk Operations');
}
if (metadata.capabilities.advancedQueries) {
capabilities.push('Advanced Queries');
}
if (metadata.capabilities.realtime) {
capabilities.push('Realtime');
}
if (metadata.capabilities.transactions) {
capabilities.push('Transactions');
}
return {
success: true,
apiMode: result.apiMode,
capabilities
};
} catch (error) {
return {
success: false,
apiMode: 'legacy', // Default fallback
capabilities: [],
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
}
/**
* Convenience function for quick adapter creation
*/
export async function createDatabaseAdapter(
endpoint: string,
project: string,
apiKey?: string,
mode: 'auto' | 'legacy' | 'tablesdb' = 'auto',
sessionCookie?: string
): Promise<DatabaseAdapter> {
const result = await AdapterFactory.create({
appwriteEndpoint: endpoint,
appwriteProject: project,
appwriteKey: apiKey,
apiMode: mode,
sessionCookie
});
return result.adapter;
}
/**
* Helper function to get adapter metadata without creating full adapter
*/
export async function getApiCapabilities(
endpoint: string,
project: string,
apiKey?: string,
sessionCookie?: string
): Promise<{
apiMode: ApiMode;
terminology: { container: string; item: string; service: string };
capabilities: string[];
}> {
const adapter = await createDatabaseAdapter(endpoint, project, apiKey, 'auto', sessionCookie);
const metadata = adapter.getMetadata();
const capabilities = [];
if (metadata.capabilities.bulkOperations) capabilities.push('Bulk Operations');
if (metadata.capabilities.advancedQueries) capabilities.push('Advanced Queries');
if (metadata.capabilities.realtime) capabilities.push('Realtime');
if (metadata.capabilities.transactions) capabilities.push('Transactions');
return {
apiMode: metadata.apiMode,
terminology: metadata.terminology,
capabilities
};
}