UNPKG

@smartsamurai/krapi-sdk

Version:

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

755 lines (681 loc) 22.4 kB
import { KrapiError } from "./core/krapi-error"; import { SQLiteSchemaInspector } from "./sqlite-schema-inspector"; import { Collection, CollectionField, FieldType, FieldValidation, RelationConfig, CollectionIndex, FieldDefinition, } from "./types"; /** * Collections Schema Manager * * Manages dynamic collection schemas that can be created at runtime by admin users. * Provides schema validation, type inference, and auto-fix capabilities. * * @class CollectionsSchemaManager * @example * const manager = new CollectionsSchemaManager(dbConnection, console); * const collection = await manager.createCollection({ * name: 'users', * fields: [{ name: 'email', type: FieldType.string, required: true }] * }); */ export class CollectionsSchemaManager { private collections: Map<string, Collection> = new Map(); private schemaInspector: SQLiteSchemaInspector; constructor( private dbConnection: { query: (sql: string, params?: unknown[]) => Promise<{ rows?: unknown[] }>; }, private logger: Console = console ) { this.schemaInspector = new SQLiteSchemaInspector(dbConnection, logger); } /** * Create a new collection with custom schema */ async createCollection(collectionData: { name: string; project_id?: string; description?: string; fields: Array<{ name: string; type: FieldType; required?: boolean; unique?: boolean; indexed?: boolean; default?: unknown; description?: string; validation?: FieldValidation; relation?: RelationConfig; }>; indexes?: Array<{ name: string; fields: string[]; unique?: boolean; }>; }, createdBy?: string): Promise<Collection> { const collection: Collection = { id: this.generateCollectionId(), project_id: collectionData.project_id || "default", name: collectionData.name, schema: { fields: collectionData.fields.map((field) => { const mappedField: FieldDefinition = { name: field.name, type: field.type, required: field.required ?? false, unique: field.unique ?? false, description: field.description || "", }; if (field.default !== undefined) { mappedField.default_value = field.default; } if (field.validation !== undefined) { mappedField.validation = field.validation; } if (field.relation !== undefined) { mappedField.options = { ...mappedField.options, reference_collection: field.relation.target_collection || "", }; } return mappedField; }), indexes: collectionData.indexes || [], }, settings: { read_permissions: [], write_permissions: [], delete_permissions: [], enable_audit_log: false, enable_soft_delete: false, enable_versioning: false, }, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; // Add description if provided if (collectionData.description !== undefined) { collection.description = collectionData.description; } // Add backward compatibility properties collection.fields = collection.schema.fields; if (collection.schema.indexes !== undefined) { collection.indexes = collection.schema.indexes; } // Create the collection in the database await this.createCollectionTable(collection); // Insert collection metadata into collections table await this.insertCollectionMetadata(collection, createdBy); // Store in memory this.collections.set(collection.id, collection); this.logger.info( `Created collection: ${collection.name} with ${collection.schema.fields.length} fields` ); return collection; } /** * Insert collection metadata into collections table */ private async insertCollectionMetadata(collection: Collection, createdBy?: string): Promise<void> { try { await this.dbConnection.query( `INSERT INTO collections (id, project_id, name, description, fields, indexes, created_at, updated_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (project_id, name) DO UPDATE SET description = EXCLUDED.description, fields = EXCLUDED.fields, indexes = EXCLUDED.indexes, updated_at = EXCLUDED.updated_at`, [ collection.id, collection.project_id, collection.name, collection.description || null, JSON.stringify(collection.schema.fields), JSON.stringify(collection.schema.indexes), collection.created_at, collection.updated_at, createdBy || null, ] ); } catch (error) { this.logger.error("Error inserting collection metadata:", error); // Don't throw here - the collection table was created successfully // This is just metadata for the SDK to find the collection } } /** * Update collection metadata in collections table */ private async updateCollectionMetadata(collection: Collection): Promise<void> { try { await this.dbConnection.query( `UPDATE collections SET description = $1, fields = $2, indexes = $3, updated_at = $4 WHERE project_id = $5 AND name = $6`, [ collection.description || null, JSON.stringify(collection.schema.fields), JSON.stringify(collection.schema.indexes), collection.updated_at, collection.project_id, collection.name, ] ); } catch (error) { this.logger.error("Error updating collection metadata:", error); } } /** * Remove collection metadata from collections table */ private async removeCollectionMetadata(projectId: string, collectionName: string): Promise<void> { try { await this.dbConnection.query( `DELETE FROM collections WHERE project_id = $1 AND name = $2`, [projectId, collectionName] ); } catch (error) { this.logger.error("Error removing collection metadata:", error); } } /** * Update an existing collection schema */ async updateCollection( collectionId: string, updates: Partial<Collection> ): Promise<Collection> { const collection = this.collections.get(collectionId); if (!collection) { throw KrapiError.notFound(`Collection '${collectionId}' not found`, { collectionId, operation: "schemaOperation" }); } const updatedCollection: Collection = { ...collection, ...updates, updated_at: new Date().toISOString(), }; // Update the database schema await this.updateCollectionTable(collection, updatedCollection); // Update collection metadata in collections table await this.updateCollectionMetadata(updatedCollection); // Update in memory this.collections.set(collectionId, updatedCollection); this.logger.info(`Updated collection: ${updatedCollection.name}`); return updatedCollection; } /** * Delete a collection and its table */ async deleteCollection(collectionId: string): Promise<boolean> { const collection = this.collections.get(collectionId); if (!collection) { throw KrapiError.notFound(`Collection '${collectionId}' not found`, { collectionId, operation: "schemaOperation" }); } // Drop the table from database await this.dropCollectionTable(collection.name); // Remove collection metadata from collections table await this.removeCollectionMetadata(collection.project_id, collection.name); // Remove from memory this.collections.delete(collectionId); this.logger.info(`Deleted collection: ${collection.name}`); return true; } /** * Get all collections */ async getCollections(): Promise<Collection[]> { return Array.from(this.collections.values()); } /** * Get a specific collection */ async getCollection(collectionId: string): Promise<Collection | null> { return this.collections.get(collectionId) || null; } /** * Validate collection schema against database */ async validateCollectionSchema(collectionId: string): Promise<{ isValid: boolean; issues: Array<{ type: | "missing_field" | "wrong_type" | "missing_index" | "missing_constraint" | "extra_field"; field?: string; expected?: string; actual?: string; description: string; }>; }> { const collection = this.collections.get(collectionId); if (!collection) { throw KrapiError.notFound(`Collection '${collectionId}' not found`, { collectionId, operation: "schemaOperation" }); } const issues: Array<{ type: | "missing_field" | "wrong_type" | "missing_index" | "missing_constraint" | "extra_field"; field?: string; expected?: string; actual?: string; description: string; }> = []; try { // Get current database schema for this table const dbSchema = await this.schemaInspector.getTableSchema( collection.name ); // Check for missing fields for (const field of collection.schema.fields) { const dbField = dbSchema.fields.find((f) => f.name === field.name); if (!dbField) { issues.push({ type: "missing_field", field: field.name, expected: this.getFieldTypeString(field), description: `Field ${field.name} is missing from database`, }); } else if (dbField.type !== this.getFieldTypeString(field)) { issues.push({ type: "wrong_type", field: field.name, expected: this.getFieldTypeString(field), actual: dbField.type, description: `Field ${ field.name } has wrong type: expected ${this.getFieldTypeString(field)}, got ${ dbField.type }`, }); } } // Check for extra fields in database for (const dbField of dbSchema.fields) { const expectedField = collection.schema.fields.find( (f) => f.name === dbField.name ); if (!expectedField) { issues.push({ type: "extra_field", field: dbField.name, actual: dbField.type, description: `Field ${dbField.name} exists in database but not in schema`, }); } } // Check indexes for (const index of collection.schema.indexes || []) { const dbIndex = dbSchema.indexes.find((i) => i.name === index.name); if (!dbIndex) { issues.push({ type: "missing_index", field: index.fields.join(", "), description: `Index ${index.name} is missing from database`, }); } } } catch (error) { this.logger.error(`Error validating collection schema:`, error); issues.push({ type: "missing_field", description: `Failed to validate schema: ${ error instanceof Error ? error.message : "Unknown error" }`, }); } return { isValid: issues.length === 0, issues, }; } /** * Auto-fix collection schema issues */ async autoFixCollectionSchema(collectionId: string): Promise<{ success: boolean; fixesApplied: number; details: string[]; }> { const collection = this.collections.get(collectionId); if (!collection) { throw KrapiError.notFound(`Collection '${collectionId}' not found`, { collectionId, operation: "schemaOperation" }); } const validation = await this.validateCollectionSchema(collectionId); if (validation.isValid) { return { success: true, fixesApplied: 0, details: ["Collection schema is already valid"], }; } const details: string[] = []; let fixesApplied = 0; try { for (const issue of validation.issues) { switch (issue.type) { case "missing_field": if (issue.field) { const field = collection.schema.fields.find( (f) => f.name === issue.field ); if (field) { await this.addFieldToTable(collection.name, field); details.push(`Added missing field: ${issue.field}`); fixesApplied++; } } break; case "wrong_type": if (issue.field && issue.expected) { const field = collection.schema.fields.find( (f) => f.name === issue.field ); if (field) { await this.modifyFieldType( collection.name, issue.field, issue.expected ); details.push( `Fixed field type: ${issue.field} -> ${issue.expected}` ); fixesApplied++; } } break; case "missing_index": if (issue.field) { const index = (collection.schema.indexes || []).find( (i) => i.fields.join(", ") === issue.field ); if (index) { await this.createIndex(collection.name, index); details.push(`Created missing index: ${index.name}`); fixesApplied++; } } break; case "extra_field": // Optionally remove extra fields (dangerous operation) if (issue.field && this.shouldRemoveExtraField(issue.field)) { await this.removeFieldFromTable(collection.name, issue.field); details.push(`Removed extra field: ${issue.field}`); fixesApplied++; } break; } } this.logger.info( `Auto-fixed ${fixesApplied} issues for collection: ${collection.name}` ); return { success: true, fixesApplied, details, }; } catch (error) { this.logger.error(`Error auto-fixing collection schema:`, error); return { success: false, fixesApplied, details: [ `Auto-fix failed: ${ error instanceof Error ? error.message : "Unknown error" }`, ], }; } } /** * Generate TypeScript interface from collection schema */ generateTypeScriptInterface(collection: Collection): string { let interfaceCode = `export interface ${this.pascalCase( collection.name )} {\n`; for (const field of collection.schema.fields) { const type = this.getTypeScriptType(field.type); const optional = field.required ? "" : "?"; const comment = field.description ? ` // ${field.description}` : ""; interfaceCode += ` ${field.name}${optional}: ${type};${comment}\n`; } interfaceCode += "}\n"; return interfaceCode; } /** * Generate all TypeScript interfaces for collections */ generateAllTypeScriptInterfaces(): string { let code = "// Auto-generated TypeScript interfaces from collections\n\n"; for (const collection of this.collections.values()) { code += `${this.generateTypeScriptInterface(collection)}\n`; } return code; } // Private helper methods private generateCollectionId(): string { // Generate a proper UUID v4 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } private async createCollectionTable(collection: Collection): Promise<void> { const sql = this.buildCreateTableSQL(collection); await this.dbConnection.query(sql); } private async updateCollectionTable( oldCollection: Collection, newCollection: Collection ): Promise<void> { // This is a simplified version - in production you'd want more sophisticated migration logic for (const field of newCollection.schema.fields) { const oldField = oldCollection.schema.fields.find( (f) => f.name === field.name ); if (!oldField) { // Add new field await this.addFieldToTable(newCollection.name, field); } else if (oldField.type !== field.type) { // Modify field type await this.modifyFieldType( newCollection.name, field.name, this.getFieldTypeString(field) ); } } } private async dropCollectionTable(tableName: string): Promise<void> { // SQLite doesn't support CASCADE in DROP TABLE const sql = `DROP TABLE IF EXISTS "${tableName}"`; await this.dbConnection.query(sql); } private async addFieldToTable( tableName: string, field: CollectionField ): Promise<void> { let sql = `ALTER TABLE "${tableName}" ADD COLUMN "${ field.name }" ${this.getFieldTypeString(field)}`; if (!field.required) { sql += " NULL"; } if (field.default !== undefined) { sql += ` DEFAULT ${this.formatDefaultValue(field.default)}`; } await this.dbConnection.query(sql); } private async modifyFieldType( tableName: string, fieldName: string, newType: string ): Promise<void> { const sql = `ALTER TABLE "${tableName}" ALTER COLUMN "${fieldName}" TYPE ${newType}`; await this.dbConnection.query(sql); } private async removeFieldFromTable( tableName: string, fieldName: string ): Promise<void> { const sql = `ALTER TABLE "${tableName}" DROP COLUMN "${fieldName}"`; await this.dbConnection.query(sql); } private async createIndex( tableName: string, index: CollectionIndex ): Promise<void> { const unique = index.unique ? "UNIQUE " : ""; const fields = index.fields.map((f) => `"${f}"`).join(", "); const sql = `CREATE ${unique}INDEX "${index.name}" ON "${tableName}" (${fields})`; await this.dbConnection.query(sql); } private getFieldTypeString(field: CollectionField): string { // SQLite-compatible type mapping switch (field.type) { case "string": return "TEXT"; case "number": return "INTEGER"; case "boolean": return "INTEGER"; // SQLite uses INTEGER 1/0 for booleans case "date": return "TEXT"; // SQLite stores dates as TEXT case "array": return "TEXT"; // SQLite stores arrays as JSON strings case "object": return "TEXT"; // SQLite stores objects as JSON strings case "uniqueID": return "TEXT"; // SQLite stores UUIDs as TEXT case "relation": return "TEXT"; // SQLite stores UUIDs as TEXT case "json": return "TEXT"; // SQLite stores JSON as TEXT case "text": return "TEXT"; default: return "TEXT"; } } private getTypeScriptType(fieldType: FieldType): string { switch (fieldType) { case "string": return "string"; case "number": return "number"; case "boolean": return "boolean"; case "date": return "Date"; case "array": return "unknown[]"; case "object": return "Record<string, unknown>"; case "uniqueID": return "string"; case "relation": return "string"; case "json": return "Record<string, unknown>"; case "text": return "string"; default: return "unknown"; } } private formatDefaultValue(value: unknown): string { if (typeof value === "string") { return `'${value}'`; } if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (value === null) { return "NULL"; } return `'${JSON.stringify(value)}'`; } private pascalCase(str: string): string { return str .split(/[-_\s]+/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(""); } private shouldRemoveExtraField(fieldName: string): boolean { // Don't remove system fields const systemFields = ["id", "created_at", "updated_at", "project_id"]; return !systemFields.includes(fieldName); } private buildCreateTableSQL(collection: Collection): string { let sql = `CREATE TABLE IF NOT EXISTS "${collection.name}" (\n`; // Add essential system fields first (SQLite-compatible syntax) sql += ` "id" TEXT PRIMARY KEY,\n`; sql += ` "project_id" TEXT NOT NULL,\n`; sql += ` "created_at" TEXT DEFAULT CURRENT_TIMESTAMP,\n`; sql += ` "updated_at" TEXT DEFAULT CURRENT_TIMESTAMP`; // Add custom fields, filtering out any that conflict with system fields const systemFields = ["id", "created_at", "updated_at", "project_id"]; const customFields = collection.schema.fields.filter( (field) => !systemFields.includes(field.name) ); if (customFields.length > 0) { sql += ",\n"; const fieldDefinitions = customFields.map((field) => { let def = ` "${field.name}" ${this.getFieldTypeString(field)}`; if (!field.required) { def += " NULL"; } if (field.default !== undefined) { def += ` DEFAULT ${this.formatDefaultValue(field.default)}`; } return def; }); sql += fieldDefinitions.join(",\n"); } sql += "\n)"; return sql; } // Field finder available for collection operations // @ts-expect-error - Method reserved for future use private _findFieldByName(collection: Collection, fieldName: string) { const expectedField = collection.schema.fields.find( (f) => f.name === fieldName ); if (!expectedField) { throw KrapiError.notFound(`Field '${fieldName}' not found`, { fieldName, operation: "getFieldDefinition", collectionName: collection.name }); } return expectedField; } }