UNPKG

@smartsamurai/krapi-sdk

Version:

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

738 lines (657 loc) 20.2 kB
/** * Metadata Manager * * Manages collection metadata including custom fields, validation rules, and schema versions. * Provides metadata persistence and querying capabilities. * * @module metadata-manager * @example * const manager = new MetadataManager(dbConnection, console); * await manager.initializeMetadataTables(); * const field = await manager.addCustomField({ collection_name: 'users', field_name: 'age', field_type: 'number' }); */ import crypto from "crypto"; import { DatabaseConnection, Logger } from "./core"; import { KrapiError } from "./core/krapi-error"; /** * Custom Field Interface * * @interface CustomField * @property {string} id - Field ID * @property {string} collection_name - Collection name * @property {string} field_name - Field name * @property {string} field_type - Field type * @property {string} [display_name] - Display name * @property {string} [description] - Field description * @property {boolean} required - Whether field is required * @property {boolean} unique - Whether field is unique * @property {unknown} [default_value] - Default value * @property {Record<string, unknown>} [validation] - Validation rules * @property {Record<string, unknown>} [metadata] - Additional metadata * @property {string} created_at - Creation timestamp * @property {string} updated_at - Update timestamp */ export interface CustomField { id: string; collection_name: string; field_name: string; field_type: string; display_name?: string; description?: string; required: boolean; unique: boolean; default_value?: unknown; validation?: Record<string, unknown>; metadata?: Record<string, unknown>; created_at: string; updated_at: string; } /** * Collection Metadata Interface * * @interface CollectionMetadata * @property {string} id - Metadata ID * @property {string} collection_name - Collection name * @property {string} version - Schema version * @property {Record<string, unknown>} schema - Collection schema * @property {CustomField[]} custom_fields - Custom fields * @property {Record<string, unknown>} validation_rules - Validation rules * @property {string} created_at - Creation timestamp * @property {string} updated_at - Update timestamp */ export interface CollectionMetadata { id: string; collection_name: string; version: string; schema: Record<string, unknown>; custom_fields: CustomField[]; validation_rules: Record<string, unknown>; created_at: string; updated_at: string; } /** * Metadata Query Interface * * @interface MetadataQuery * @property {string} [collection_name] - Filter by collection name * @property {string} [field_type] - Filter by field type * @property {boolean} [required] - Filter by required status * @property {boolean} [unique] - Filter by unique status */ export interface MetadataQuery { collection_name?: string; field_type?: string; required?: boolean; unique?: boolean; } /** * Metadata Manager Class * * Manages collection metadata including custom fields and validation rules. * * @class MetadataManager * @example * const manager = new MetadataManager(dbConnection, console); * await manager.initializeMetadataTables(); * const metadata = await manager.getCollectionMetadata('users'); */ export class MetadataManager { private db: DatabaseConnection; private logger: Logger; private initialized = false; constructor( databaseConnection: DatabaseConnection, logger: Logger = console ) { this.db = databaseConnection; this.logger = logger; } /** * Initialize metadata tables */ async initializeMetadataTables(): Promise<void> { if (this.initialized) { return; } try { // Wait for admin_users table to exist await this.waitForTable("admin_users"); // Create custom_fields table (SQLite-compatible) await this.db.query(` CREATE TABLE IF NOT EXISTS custom_fields ( id TEXT PRIMARY KEY, collection_name TEXT NOT NULL, field_name TEXT NOT NULL, field_type TEXT NOT NULL, display_name TEXT, description TEXT, required INTEGER DEFAULT 0, "unique" INTEGER DEFAULT 0, default_value TEXT, validation TEXT, metadata TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(collection_name, field_name) ) `); // Create collection_metadata table (SQLite-compatible) await this.db.query(` CREATE TABLE IF NOT EXISTS collection_metadata ( id TEXT PRIMARY KEY, collection_name TEXT NOT NULL UNIQUE, version TEXT NOT NULL DEFAULT '1.0.0', schema TEXT NOT NULL DEFAULT '{}', custom_fields TEXT DEFAULT '[]', validation_rules TEXT DEFAULT '{}', created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); this.initialized = true; this.logger.info("Metadata tables initialized successfully"); } catch (error) { this.logger.error("Failed to initialize metadata tables:", error); throw error; } } /** * Wait for a table to exist */ private async waitForTable(tableName: string): Promise<void> { let attempts = 0; const maxAttempts = 10; while (attempts < maxAttempts) { try { const result = await this.db.query( "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = $1)", [tableName] ); if ( result.rows?.[0] && (result.rows[0] as Record<string, unknown>).exists ) { return; } attempts++; await new Promise((resolve) => setTimeout(resolve, 1000)); } catch { attempts++; await new Promise((resolve) => setTimeout(resolve, 1000)); } } throw KrapiError.notFound(`Table '${tableName}' not found after ${maxAttempts} attempts`, { tableName, operation: "ensureTableExists", maxAttempts }); } /** * Add a custom field to a collection */ async addCustomField( field: Omit<CustomField, "id" | "created_at" | "updated_at"> ): Promise<CustomField> { await this.ensureInitialized(); try { // Generate field ID (SQLite doesn't support RETURNING *) const fieldId = crypto.randomUUID(); // SQLite-compatible INSERT (no RETURNING *) await this.db.query( ` INSERT INTO custom_fields ( id, collection_name, field_name, field_type, display_name, description, required, "unique", default_value, validation, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `, [ fieldId, field.collection_name, field.field_name, field.field_type, field.display_name, field.description, field.required ? 1 : 0, // SQLite uses INTEGER 1/0 for booleans field.unique ? 1 : 0, // SQLite uses INTEGER 1/0 for booleans JSON.stringify(field.default_value || null), JSON.stringify(field.validation || {}), JSON.stringify(field.metadata || {}), ] ); // Query back the inserted row const result = await this.db.query( "SELECT * FROM custom_fields WHERE id = $1", [fieldId] ); return result.rows[0] as CustomField; } catch (error) { this.logger.error("Failed to add custom field:", error); throw error; } } /** * Update a custom field */ async updateCustomField( fieldId: string, updates: Partial<Omit<CustomField, "id" | "created_at" | "updated_at">> ): Promise<CustomField | null> { await this.ensureInitialized(); try { const setClauses: string[] = []; const params: unknown[] = []; let paramCount = 0; if (updates.field_name !== undefined) { paramCount++; setClauses.push(`field_name = $${paramCount}`); params.push(updates.field_name); } if (updates.field_type !== undefined) { paramCount++; setClauses.push(`field_type = $${paramCount}`); params.push(updates.field_type); } if (updates.display_name !== undefined) { paramCount++; setClauses.push(`display_name = $${paramCount}`); params.push(updates.display_name); } if (updates.description !== undefined) { paramCount++; setClauses.push(`description = $${paramCount}`); params.push(updates.description); } if (updates.required !== undefined) { paramCount++; setClauses.push(`required = $${paramCount}`); params.push(updates.required); } if (updates.unique !== undefined) { paramCount++; setClauses.push(`"unique" = $${paramCount}`); params.push(updates.unique); } if (updates.default_value !== undefined) { paramCount++; setClauses.push(`default_value = $${paramCount}`); params.push(JSON.stringify(updates.default_value)); } if (updates.validation !== undefined) { paramCount++; setClauses.push(`validation = $${paramCount}`); params.push(JSON.stringify(updates.validation)); } if (updates.metadata !== undefined) { paramCount++; setClauses.push(`metadata = $${paramCount}`); params.push(JSON.stringify(updates.metadata)); } if (setClauses.length === 0) { return null; } paramCount++; setClauses.push(`updated_at = $${paramCount}`); params.push(new Date().toISOString()); paramCount++; params.push(fieldId); // SQLite doesn't support RETURNING *, so update and query back separately await this.db.query( ` UPDATE custom_fields SET ${setClauses.join(", ")} WHERE id = $${paramCount} `, params ); // Query back the updated row const result = await this.db.query( "SELECT * FROM custom_fields WHERE id = $1", [fieldId] ); return result.rows.length > 0 ? (result.rows[0] as CustomField) : null; } catch (error) { this.logger.error("Failed to update custom field:", error); throw error; } } /** * Remove a custom field */ async removeCustomField(fieldId: string): Promise<boolean> { await this.ensureInitialized(); try { // SQLite-compatible: delete and check rowCount (SQLite 3.35.0+ supports RETURNING, but we use compatible approach) const result = await this.db.query( "DELETE FROM custom_fields WHERE id = $1", [fieldId] ); return (result.rowCount || 0) > 0; } catch (error) { this.logger.error("Failed to remove custom field:", error); throw error; } } /** * Get custom fields for a collection */ async getCustomFields(collectionName?: string): Promise<CustomField[]> { await this.ensureInitialized(); try { let query = "SELECT * FROM custom_fields"; const params: unknown[] = []; if (collectionName) { query += " WHERE collection_name = $1"; params.push(collectionName); } query += " ORDER BY created_at DESC"; const result = await this.db.query(query, params); return result.rows as CustomField[]; } catch (error) { this.logger.error("Failed to get custom fields:", error); throw error; } } /** * Get collection metadata */ async getCollectionMetadata( collectionName: string ): Promise<CollectionMetadata | null> { await this.ensureInitialized(); try { const result = await this.db.query( "SELECT * FROM collection_metadata WHERE collection_name = $1", [collectionName] ); if (result.rows.length === 0) { return null; } const metadata = result.rows[0] as CollectionMetadata; // Parse JSON fields metadata.custom_fields = metadata.custom_fields || []; metadata.schema = metadata.schema || {}; metadata.validation_rules = metadata.validation_rules || {}; return metadata; } catch (error) { this.logger.error("Failed to get collection metadata:", error); throw error; } } /** * Update collection metadata version */ async updateCollectionMetadataVersion( collectionName: string, version: string, schema?: Record<string, unknown> ): Promise<CollectionMetadata> { await this.ensureInitialized(); try { const metadataId = crypto.randomUUID(); const now = new Date().toISOString(); // SQLite uses INSERT OR REPLACE instead of ON CONFLICT DO UPDATE await this.db.query( ` INSERT INTO collection_metadata (id, collection_name, version, schema, updated_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (collection_name) DO UPDATE SET version = $3, schema = $4, updated_at = $5 `, [ metadataId, collectionName, version, JSON.stringify(schema || {}), now, ] ); // Query back the upserted row const result = await this.db.query( "SELECT * FROM collection_metadata WHERE collection_name = $1", [collectionName] ); return result.rows[0] as CollectionMetadata; } catch (error) { this.logger.error("Failed to update collection metadata version:", error); throw error; } } /** * Log validation change */ async logValidationChange( collectionName: string, changeType: string, details: Record<string, unknown> ): Promise<void> { await this.ensureInitialized(); try { await this.db.query( ` INSERT INTO collection_metadata (collection_name, validation_rules, updated_at) VALUES ($1, $2, $3) ON CONFLICT (collection_name) DO UPDATE SET validation_rules = EXCLUDED.validation_rules, updated_at = EXCLUDED.updated_at `, [ collectionName, JSON.stringify({ [changeType]: details, timestamp: new Date().toISOString(), }), new Date().toISOString(), ] ); } catch (error) { this.logger.error("Failed to log validation change:", error); throw error; } } /** * Validate a document against custom fields */ async validateDocument( collectionName: string, document: Record<string, unknown> ): Promise<{ isValid: boolean; errors: string[] }> { await this.ensureInitialized(); try { const customFields = await this.getCustomFields(collectionName); const errors: string[] = []; for (const field of customFields) { const value = document[field.field_name]; // Check required fields if ( field.required && (value === undefined || value === null || value === "") ) { errors.push(`Field '${field.field_name}' is required`); continue; } // Skip validation for undefined values if (value === undefined || value === null) { continue; } // Validate field type const typeValid = this.validateFieldType(field.field_type, value); if (!typeValid) { errors.push( `Field '${field.field_name}' has invalid type. Expected ${ field.field_type }, got ${typeof value}` ); } // Validate field rules if (field.validation) { const ruleErrors = this.validateFieldRules( field.field_name, value, field.validation ); errors.push(...ruleErrors); } // Check uniqueness if required if (field.unique) { const isUnique = await this.checkFieldUniqueness( collectionName, field.field_name ); if (!isUnique) { errors.push(`Field '${field.field_name}' must be unique`); } } } return { isValid: errors.length === 0, errors, }; } catch (error) { this.logger.error("Failed to validate document:", error); throw error; } } /** * Validate field type */ private validateFieldType(expectedType: string, value: unknown): boolean { switch (expectedType.toLowerCase()) { case "string": case "text": case "varchar": return typeof value === "string"; case "integer": case "int": case "bigint": return Number.isInteger(value); case "decimal": case "numeric": case "real": case "double": return typeof value === "number"; case "boolean": case "bool": return typeof value === "boolean"; case "date": case "timestamp": return value instanceof Date || typeof value === "string"; case "json": case "jsonb": return typeof value === "object" && value !== null; case "array": return Array.isArray(value); default: return true; // Unknown types are considered valid } } /** * Validate field rules */ private validateFieldRules( fieldName: string, value: unknown, rules: Record<string, unknown> ): string[] { const errors: string[] = []; if ( rules.min_length && typeof value === "string" && value.length < (rules.min_length as number) ) { errors.push( `Field '${fieldName}' must be at least ${ rules.min_length as number } characters long` ); } if ( rules.max_length && typeof value === "string" && value.length > (rules.max_length as number) ) { errors.push( `Field '${fieldName}' must be at most ${ rules.max_length as number } characters long` ); } if ( rules.min_value && typeof value === "number" && value < (rules.min_value as number) ) { errors.push( `Field '${fieldName}' must be at least ${rules.min_value as number}` ); } if ( rules.max_value && typeof value === "number" && value > (rules.max_value as number) ) { errors.push( `Field '${fieldName}' must be at most ${rules.max_value as number}` ); } if (rules.pattern && typeof value === "string") { const regex = new RegExp(rules.pattern as string); if (!regex.test(value)) { errors.push( `Field '${fieldName}' does not match pattern ${ rules.pattern as string }` ); } } if ( rules.allowed_values && !(rules.allowed_values as unknown[]).includes(value) ) { errors.push( `Field '${fieldName}' must be one of: ${( rules.allowed_values as unknown[] ).join(", ")}` ); } return errors; } /** * Check field uniqueness */ private async checkFieldUniqueness( collectionName: string, fieldName: string ): Promise<boolean> { try { // This is a simplified check - in a real implementation, // you'd need to check against the actual collection table const result = await this.db.query( ` SELECT COUNT(*) as count FROM custom_fields WHERE collection_name = $1 AND field_name = $2 AND "unique" = true `, [collectionName, fieldName] ); return ( parseInt( ((result.rows[0] as Record<string, unknown>)?.count as string) || "0" ) === 0 ); } catch (error) { this.logger.error("Failed to check field uniqueness:", error); return false; } } /** * Ensure the manager is initialized */ private async ensureInitialized(): Promise<void> { if (!this.initialized) { await this.initializeMetadataTables(); } } }