UNPKG

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.

409 lines (352 loc) 13.7 kB
import type { Index } from "appwrite-utils"; import type { DatabaseAdapter } from "../adapters/DatabaseAdapter.js"; import type { Models } from "node-appwrite"; import { isIndexEqualToIndex } from "../collections/tableOperations.js"; import { MessageFormatter } from "../shared/messageFormatter.js"; import { delay, tryAwaitWithRetry } from "../utils/helperFunctions.js"; // Enhanced index operation interfaces export interface IndexOperation { type: 'create' | 'update' | 'skip' | 'delete'; index: Index; existingIndex?: Models.Index; reason?: string; } export interface IndexOperationPlan { toCreate: IndexOperation[]; toUpdate: IndexOperation[]; toSkip: IndexOperation[]; toDelete: IndexOperation[]; } export interface IndexExecutionResult { created: string[]; updated: string[]; skipped: string[]; deleted: string[]; errors: Array<{ key: string; error: string }>; summary: { total: number; created: number; updated: number; skipped: number; deleted: number; errors: number; }; } /** * Plan index operations by comparing desired indexes with existing ones * Uses the existing isIndexEqualToIndex function for consistent comparison */ export function planIndexOperations( desiredIndexes: Index[], existingIndexes: Models.Index[] ): IndexOperationPlan { const plan: IndexOperationPlan = { toCreate: [], toUpdate: [], toSkip: [], toDelete: [] }; for (const desiredIndex of desiredIndexes) { const existingIndex = existingIndexes.find(idx => idx.key === desiredIndex.key); if (!existingIndex) { // Index doesn't exist - create it plan.toCreate.push({ type: 'create', index: desiredIndex, reason: 'New index' }); } else if (isIndexEqualToIndex(existingIndex, desiredIndex)) { // Index exists and is identical - skip it plan.toSkip.push({ type: 'skip', index: desiredIndex, existingIndex, reason: 'Index unchanged' }); } else { // Index exists but is different - update it plan.toUpdate.push({ type: 'update', index: desiredIndex, existingIndex, reason: 'Index configuration changed' }); } } return plan; } /** * Plan index deletions for indexes that exist but aren't in the desired configuration */ export function planIndexDeletions( desiredIndexKeys: Set<string>, existingIndexes: Models.Index[] ): IndexOperation[] { const deletions: IndexOperation[] = []; for (const existingIndex of existingIndexes) { if (!desiredIndexKeys.has(existingIndex.key)) { deletions.push({ type: 'delete', index: existingIndex as Index, // Convert Models.Index to Index for compatibility reason: 'Obsolete index' }); } } return deletions; } /** * Execute index operations with proper error handling and status monitoring */ export async function executeIndexOperations( adapter: DatabaseAdapter, databaseId: string, tableId: string, plan: IndexOperationPlan ): Promise<IndexExecutionResult> { const result: IndexExecutionResult = { created: [], updated: [], skipped: [], deleted: [], errors: [], summary: { total: 0, created: 0, updated: 0, skipped: 0, deleted: 0, errors: 0 } }; // Execute creates for (const operation of plan.toCreate) { try { await adapter.createIndex({ databaseId, tableId, key: operation.index.key, type: operation.index.type, attributes: operation.index.attributes, orders: operation.index.orders || [] }); result.created.push(operation.index.key); MessageFormatter.success(`Created index ${operation.index.key}`, { prefix: 'Indexes' }); // Wait for index to become available await waitForIndexAvailable(adapter, databaseId, tableId, operation.index.key); await delay(150); // Brief delay between operations } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors.push({ key: operation.index.key, error: errorMessage }); MessageFormatter.error(`Failed to create index ${operation.index.key}`, error instanceof Error ? error : new Error(String(error)), { prefix: 'Indexes' }); } } // Execute updates (delete + recreate) for (const operation of plan.toUpdate) { try { // Delete existing index first await adapter.deleteIndex({ databaseId, tableId, key: operation.index.key }); await delay(100); // Brief delay for deletion to settle // Create new index await adapter.createIndex({ databaseId, tableId, key: operation.index.key, type: operation.index.type, attributes: operation.index.attributes, orders: operation.index.orders || operation.existingIndex?.orders || [] }); result.updated.push(operation.index.key); MessageFormatter.success(`Updated index ${operation.index.key}`, { prefix: 'Indexes' }); // Wait for index to become available await waitForIndexAvailable(adapter, databaseId, tableId, operation.index.key); await delay(150); // Brief delay between operations } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors.push({ key: operation.index.key, error: errorMessage }); MessageFormatter.error(`Failed to update index ${operation.index.key}`, error instanceof Error ? error : new Error(String(error)), { prefix: 'Indexes' }); } } // Execute skips for (const operation of plan.toSkip) { result.skipped.push(operation.index.key); MessageFormatter.info(`Index ${operation.index.key} unchanged`, { prefix: 'Indexes' }); } // Calculate summary result.summary.total = result.created.length + result.updated.length + result.skipped.length + result.deleted.length; result.summary.created = result.created.length; result.summary.updated = result.updated.length; result.summary.skipped = result.skipped.length; result.summary.deleted = result.deleted.length; result.summary.errors = result.errors.length; return result; } /** * Execute index deletions with proper error handling */ export async function executeIndexDeletions( adapter: DatabaseAdapter, databaseId: string, tableId: string, deletions: IndexOperation[] ): Promise<{ deleted: string[]; errors: Array<{ key: string; error: string }> }> { const result = { deleted: [] as string[], errors: [] as Array<{ key: string; error: string }> }; for (const operation of deletions) { try { await adapter.deleteIndex({ databaseId, tableId, key: operation.index.key }); result.deleted.push(operation.index.key); MessageFormatter.info(`Deleted obsolete index ${operation.index.key}`, { prefix: 'Indexes' }); // Wait briefly for deletion to settle await delay(500); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors.push({ key: operation.index.key, error: errorMessage }); MessageFormatter.error(`Failed to delete index ${operation.index.key}`, error instanceof Error ? error : new Error(String(error)), { prefix: 'Indexes' }); } } return result; } /** * Wait for an index to become available with timeout and retry logic * This is an adapter-aware version of the logic from collections/indexes.ts */ async function waitForIndexAvailable( adapter: DatabaseAdapter, databaseId: string, tableId: string, indexKey: string, maxWaitTime: number = 60000, // 1 minute checkInterval: number = 2000 // 2 seconds ): Promise<boolean> { const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { try { const indexList = await adapter.listIndexes({ databaseId, tableId }); const indexes: any[] = (indexList as any).data || (indexList as any).indexes || []; const index = indexes.find((idx: any) => idx.key === indexKey); if (!index) { MessageFormatter.error(`Index '${indexKey}' not found after creation`, undefined, { prefix: 'Indexes' }); return false; } switch (index.status) { case 'available': return true; case 'failed': MessageFormatter.error(`Index '${indexKey}' failed: ${index.error || 'unknown error'}`, undefined, { prefix: 'Indexes' }); return false; case 'stuck': MessageFormatter.warning(`Index '${indexKey}' is stuck`, { prefix: 'Indexes' }); return false; case 'processing': case 'deleting': // Continue waiting break; default: MessageFormatter.warning(`Unknown status '${index.status}' for index '${indexKey}'`, { prefix: 'Indexes' }); break; } } catch (error) { MessageFormatter.error(`Error checking index '${indexKey}' status: ${error}`, undefined, { prefix: 'Indexes' }); } await delay(checkInterval); } MessageFormatter.warning(`Timeout waiting for index '${indexKey}' to become available (${maxWaitTime}ms)`, { prefix: 'Indexes' }); return false; } /** * Main function to create/update indexes via adapter * This replaces the messy inline code in methods.ts */ export async function createOrUpdateIndexesViaAdapter( adapter: DatabaseAdapter, databaseId: string, tableId: string, desiredIndexes: Index[], configIndexes?: Index[] ): Promise<void> { if (!desiredIndexes || desiredIndexes.length === 0) { MessageFormatter.info('No indexes to process', { prefix: 'Indexes' }); return; } MessageFormatter.info(`Processing ${desiredIndexes.length} indexes for table ${tableId}`, { prefix: 'Indexes' }); try { // Get existing indexes const existingIdxRes = await adapter.listIndexes({ databaseId, tableId }); const existingIndexes: Models.Index[] = (existingIdxRes as any).data || (existingIdxRes as any).indexes || []; // Plan operations const plan = planIndexOperations(desiredIndexes, existingIndexes); // Show plan with icons (consistent with attribute handling) const planParts: string[] = []; if (plan.toCreate.length) planParts.push(`➕ ${plan.toCreate.length} (${plan.toCreate.map(op => op.index.key).join(', ')})`); if (plan.toUpdate.length) planParts.push(`🔧 ${plan.toUpdate.length} (${plan.toUpdate.map(op => op.index.key).join(', ')})`); if (plan.toSkip.length) planParts.push(`⏭️ ${plan.toSkip.length}`); MessageFormatter.info(`Plan → ${planParts.join(' | ') || 'no changes'}`, { prefix: 'Indexes' }); // Execute operations const result = await executeIndexOperations(adapter, databaseId, tableId, plan); // Show summary MessageFormatter.info( `Summary → ➕ ${result.summary.created} | 🔧 ${result.summary.updated} | ⏭️ ${result.summary.skipped}`, { prefix: 'Indexes' } ); // Handle errors if any if (result.errors.length > 0) { MessageFormatter.error(`${result.errors.length} index operations failed:`, undefined, { prefix: 'Indexes' }); for (const error of result.errors) { MessageFormatter.error(` ${error.key}: ${error.error}`, undefined, { prefix: 'Indexes' }); } } } catch (error) { MessageFormatter.error('Failed to process indexes', error instanceof Error ? error : new Error(String(error)), { prefix: 'Indexes' }); throw error; } } /** * Handle index deletions for obsolete indexes */ export async function deleteObsoleteIndexesViaAdapter( adapter: DatabaseAdapter, databaseId: string, tableId: string, desiredIndexKeys: Set<string> ): Promise<void> { try { // Get existing indexes const existingIdxRes = await adapter.listIndexes({ databaseId, tableId }); const existingIndexes: Models.Index[] = (existingIdxRes as any).data || (existingIdxRes as any).indexes || []; // Plan deletions const deletions = planIndexDeletions(desiredIndexKeys, existingIndexes); if (deletions.length === 0) { MessageFormatter.info('Plan → 🗑️ 0 indexes', { prefix: 'Indexes' }); return; } // Show deletion plan MessageFormatter.info( `Plan → 🗑️ ${deletions.length} (${deletions.map(op => op.index.key).join(', ')})`, { prefix: 'Indexes' } ); // Execute deletions const result = await executeIndexDeletions(adapter, databaseId, tableId, deletions); // Show results if (result.deleted.length > 0) { MessageFormatter.success(`Deleted ${result.deleted.length} indexes: ${result.deleted.join(', ')}`, { prefix: 'Indexes' }); } if (result.errors.length > 0) { MessageFormatter.error(`${result.errors.length} index deletions failed:`, undefined, { prefix: 'Indexes' }); for (const error of result.errors) { MessageFormatter.error(` ${error.key}: ${error.error}`, undefined, { prefix: 'Indexes' }); } } } catch (error) { MessageFormatter.warning(`Could not evaluate index deletions: ${(error as Error)?.message || error}`, { prefix: 'Indexes' }); } }