UNPKG

@smartsamurai/krapi-sdk

Version:

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

1,659 lines (1,537 loc) 75.3 kB
import { CollectionsSchemaManager } from "./collections-schema-manager"; import { KrapiError } from "./core/krapi-error"; import { SQLiteSchemaInspector } from "./sqlite-schema-inspector"; import { Collection, CollectionField, FieldType, FieldValidation, RelationConfig, FieldDefinition, IndexDefinition, CollectionSettings, } from "./types"; import { normalizeError } from "./utils/error-handler"; import { logServiceOperationError } from "./utils/error-logger"; export interface Document { id: string; collection_id: string; project_id: string; data: Record<string, unknown>; created_at: string; updated_at: string; created_by: string; updated_by: string; version: number; is_deleted: boolean; deleted_at?: string; deleted_by?: string; } export interface DocumentFilter { field_filters?: Record<string, unknown>; search?: string; created_after?: string; created_before?: string; updated_after?: string; updated_before?: string; created_by?: string; updated_by?: string; include_deleted?: boolean; } export interface DocumentQueryOptions { limit?: number; offset?: number; sort_by?: string; sort_order?: "asc" | "desc"; select_fields?: string[]; } export interface CreateDocumentRequest { data: Record<string, unknown>; created_by?: string; } export interface CollectionStatistics { total_documents: number; total_size_bytes: number; average_document_size: number; field_statistics: Record< string, { null_count: number; unique_values: number; most_common_values?: Array<{ value: unknown; count: number }>; } >; index_usage: Record< string, { size_bytes: number; scans: number; last_used?: string; } >; } export interface UpdateDocumentRequest { data: Record<string, unknown>; updated_by?: string; } export interface DatabaseConnection { query: ( sql: string, params?: unknown[] ) => Promise<{ rows: unknown[]; rowCount: number }>; } /** * Collections Service * * High-level service for managing dynamic collections with schema validation, * auto-fixing, and TypeScript interface generation. * * @class CollectionsService * @example * const collectionsService = new CollectionsService(dbConnection, logger); * const collection = await collectionsService.createCollection({ * name: 'users', * fields: [{ name: 'email', type: FieldType.string, required: true }] * }); */ export class CollectionsService { private schemaManager: CollectionsSchemaManager; private schemaInspector: SQLiteSchemaInspector; private db: DatabaseConnection; /** * Create a new CollectionsService instance * * @param {DatabaseConnection} databaseConnection - Database connection * @param {Console} [logger=console] - Logger instance */ constructor( databaseConnection: DatabaseConnection, private logger: Console = console ) { this.db = databaseConnection; this.schemaManager = new CollectionsSchemaManager( databaseConnection, logger ); this.schemaInspector = new SQLiteSchemaInspector( databaseConnection, logger ); } /** * Map database row to Document interface */ private mapDocument(row: Record<string, unknown>): Document { // Parse data from JSON string (SQLite stores JSON as TEXT) let parsedData: Record<string, unknown> = {}; if (typeof row.data === "string") { try { parsedData = JSON.parse(row.data); } catch (error) { this.logger.error("Error parsing document data JSON:", error); parsedData = {}; } } else if (typeof row.data === "object" && row.data !== null) { parsedData = row.data as Record<string, unknown>; } const document: Document = { id: row.id as string, collection_id: row.collection_id as string, project_id: (row.project_id as string) || "", data: parsedData, version: (row.version as number | undefined) || 1, is_deleted: (row.is_deleted as boolean | undefined) || false, created_at: row.created_at as string, updated_at: (row.updated_at as string) || row.created_at as string, created_by: (row.created_by as string | undefined) || "system", updated_by: (row.updated_by as string | undefined) || (row.created_by as string | undefined) || "system", }; if (row.deleted_at !== undefined && row.deleted_at !== null) { (document as { deleted_at?: string }).deleted_at = row.deleted_at as string; } if (row.deleted_by !== undefined && row.deleted_by !== null) { (document as { deleted_by?: string }).deleted_by = row.deleted_by as string; } return document; } /** * Create a new collection with custom schema * Example: Create an "articles" collection with title, content, author fields */ /** * Create a new collection * * Creates a new collection with the specified schema and fields. * Automatically creates the database table and indexes. * * @param {Object} collectionData - Collection creation data * @param {string} collectionData.project_id - Project ID * @param {string} collectionData.name - Collection name (required) * @param {string} [collectionData.description] - Collection description * @param {CollectionField[]} collectionData.fields - Collection field definitions * @param {CollectionSettings} [collectionData.settings] - Collection settings * @returns {Promise<Collection>} Created collection * @throws {Error} If creation fails or collection name already exists * * @example * const collection = await collectionsService.createCollection({ * project_id: 'project-id', * name: 'users', * description: 'User collection', * fields: [ * { name: 'email', type: FieldType.string, required: true }, * { name: 'name', type: FieldType.string, required: true } * ] * }); */ async createCollection(collectionData: { name: 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; }>; }): Promise<Collection> { // Validate collection name if (!this.isValidCollectionName(collectionData.name)) { throw KrapiError.validationError( "Invalid collection name. Use only letters, numbers, and underscores.", "name", collectionData.name ); } // Check if collection already exists const existingCollection = await this.getCollectionByName( collectionData.name ); if (existingCollection) { throw KrapiError.conflict( `Collection "${collectionData.name}" already exists`, { resource: "collections", operation: "createCollection", collectionName: collectionData.name } ); } // Validate fields this.validateCollectionFields(collectionData.fields); // Create the collection const collection = await this.schemaManager.createCollection( collectionData ); this.logger.info( `Created collection "${collection.name}" with ${collection.schema.fields.length} fields` ); return collection; } /** * Create a collection from a predefined template * * Creates a collection using a template (e.g., 'users', 'posts', 'products') * with optional customizations. * * @param {string} templateName - Template name * @param {Object} [customizations] - Optional customizations * @param {string} [customizations.name] - Custom collection name * @param {string} [customizations.description] - Custom description * @param {Array} [customizations.additionalFields] - Additional fields to add * @returns {Promise<Collection>} Created collection * @throws {Error} If template not found or creation fails * * @example * const collection = await collectionsService.createCollectionFromTemplate('users', { * name: 'customers', * additionalFields: [{ name: 'phone', type: FieldType.string }] * }); */ async createCollectionFromTemplate( templateName: string, customizations?: { name?: string; description?: string; additionalFields?: Array<{ name: string; type: FieldType; required?: boolean; unique?: boolean; indexed?: boolean; description?: string; }>; } ): Promise<Collection> { const template = this.getCollectionTemplate(templateName); if (!template) { throw KrapiError.notFound( `Collection template '${templateName}' not found`, { templateName, operation: "createCollectionFromTemplate" } ); } const collectionData = { name: customizations?.name || template.name, description: customizations?.description || template.description, fields: [...template.fields, ...(customizations?.additionalFields || [])], indexes: template.indexes, }; return this.createCollection(collectionData); } /** * Update collection schema * * Updates a collection's schema by adding, removing, or modifying fields. * Automatically updates the database table structure. * * @param {string} collectionId - Collection ID * @param {Object} updates - Schema updates * @param {Array} [updates.addFields] - Fields to add * @param {string[]} [updates.removeFields] - Field names to remove * @param {Array} [updates.modifyFields] - Fields to modify * @returns {Promise<Collection>} Updated collection * @throws {Error} If update fails or collection not found * * @example * const updated = await collectionsService.updateCollectionSchema('collection-id', { * addFields: [{ name: 'age', type: FieldType.number }], * removeFields: ['old_field'] * }); */ async updateCollectionSchema( collectionId: string, updates: { addFields?: Array<{ name: string; type: FieldType; required?: boolean; unique?: boolean; indexed?: boolean; default?: unknown; description?: string; validation?: FieldValidation; relation?: RelationConfig; }>; removeFields?: string[]; modifyFields?: Array<{ name: string; type?: FieldType; required?: boolean; unique?: boolean; indexed?: boolean; default?: unknown; description?: string; validation?: FieldValidation; relation?: RelationConfig; }>; addIndexes?: Array<{ name: string; fields: string[]; unique?: boolean; }>; removeIndexes?: string[]; } ): Promise<Collection> { const collection = await this.schemaManager.getCollection(collectionId); if (!collection) { throw KrapiError.notFound(`Collection ${collectionId} not found`, { collectionId, }); } // Apply updates const updatedFields = [...(collection.fields || [])]; const updatedIndexes = [...(collection.indexes || [])]; // Add new fields if (updates.addFields) { for (const field of updates.addFields) { if (updatedFields.find((f) => f.name === field.name)) { throw KrapiError.conflict(`Field "${field.name}" already exists`, { fieldName: field.name, collectionId, }); } const fieldToAdd: CollectionField = { name: field.name, type: field.type, required: field.required ?? false, unique: field.unique ?? false, indexed: field.indexed ?? false, description: field.description || "", }; if (field.default !== undefined) { fieldToAdd.default = field.default; } if (field.validation !== undefined) { fieldToAdd.validation = field.validation; } if (field.relation !== undefined) { fieldToAdd.relation = field.relation as unknown as Record< string, unknown >; } updatedFields.push(fieldToAdd); } } // Remove fields if (updates.removeFields) { for (const fieldName of updates.removeFields) { const index = updatedFields.findIndex((f) => f.name === fieldName); if (index === -1) { throw KrapiError.notFound(`Field "${fieldName}" not found`, { fieldName, collectionId, }); } updatedFields.splice(index, 1); } } // Modify fields if (updates.modifyFields) { for (const modification of updates.modifyFields) { const field = updatedFields.find((f) => f.name === modification.name); if (!field) { throw KrapiError.notFound(`Field "${modification.name}" not found`, { fieldName: modification.name, collectionId, }); } Object.assign(field, modification); if (modification.description !== undefined) { field.description = modification.description || ""; } } } // Add indexes if (updates.addIndexes) { for (const index of updates.addIndexes) { if (updatedIndexes.find((i) => i.name === index.name)) { throw KrapiError.conflict(`Index "${index.name}" already exists`, { indexName: index.name, collectionId, }); } updatedIndexes.push(index); } } // Remove indexes if (updates.removeIndexes) { for (const indexName of updates.removeIndexes) { const index = updatedIndexes.findIndex((i) => i.name === indexName); if (index === -1) { throw KrapiError.notFound(`Index "${indexName}" not found`, { indexName, collectionId, }); } updatedIndexes.splice(index, 1); } } // Update the collection const updatedCollection = await this.schemaManager.updateCollection( collectionId, { fields: updatedFields, indexes: updatedIndexes, updated_at: new Date().toISOString(), } ); this.logger.info(`Updated collection "${updatedCollection.name}" schema`); return updatedCollection; } /** * Validate collection schema against database */ async validateCollection(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; severity: "error" | "warning" | "info"; }>; recommendations: string[]; }> { const collection = await this.schemaManager.getCollection(collectionId); if (!collection) { throw KrapiError.notFound(`Collection ${collectionId} not found`, { collectionId, }); } const validation = await this.schemaManager.validateCollectionSchema( collectionId ); // Add severity levels to issues const issuesWithSeverity = validation.issues.map((issue) => ({ ...issue, severity: this.getIssueSeverity(issue.type) as | "error" | "warning" | "info", })); // Generate recommendations const recommendations = this.generateRecommendations( validation.issues, collection ); return { isValid: validation.isValid, issues: issuesWithSeverity, recommendations, }; } /** * Auto-fix collection schema issues */ async autoFixCollection(collectionId: string): Promise<{ success: boolean; fixesApplied: number; details: string[]; remainingIssues: number; }> { const result = await this.schemaManager.autoFixCollectionSchema( collectionId ); // Check if there are remaining issues after auto-fix const remainingValidation = await this.validateCollection(collectionId); return { ...result, remainingIssues: remainingValidation.issues.length, }; } /** * Generate TypeScript interface for a collection */ async generateTypeScriptInterface(collectionId: string): Promise<string> { const collection = await this.schemaManager.getCollection(collectionId); if (!collection) { throw KrapiError.notFound(`Collection ${collectionId} not found`, { collectionId, }); } return this.schemaManager.generateTypeScriptInterface(collection); } /** * Generate all TypeScript interfaces */ async generateAllTypeScriptInterfaces(): Promise<string> { return this.schemaManager.generateAllTypeScriptInterfaces(); } /** * Get collection health status */ async getCollectionHealth(collectionId: string): Promise<{ status: "healthy" | "degraded" | "unhealthy"; schemaValid: boolean; dataIntegrity: { hasNullViolations: boolean; hasUniqueViolations: boolean; hasForeignKeyViolations: boolean; issues: string[]; }; tableStats: { rowCount: number; sizeBytes: number; indexSizeBytes: number; }; lastValidated: string; }> { const collection = await this.schemaManager.getCollection(collectionId); if (!collection) { throw KrapiError.notFound(`Collection ${collectionId} not found`, { collectionId, }); } const [validation, dataIntegrity, tableStats] = await Promise.all([ this.validateCollection(collectionId), this.schemaInspector.checkTableIntegrity(collection.name), this.schemaInspector.getTableStats(collection.name), ]); const status = this.determineHealthStatus( validation.isValid, dataIntegrity ); return { status, schemaValid: validation.isValid, dataIntegrity, tableStats, lastValidated: new Date().toISOString(), }; } /** * Get all collections with health status */ async getAllCollectionsWithHealth(): Promise< Array< Collection & { health: { status: "healthy" | "degraded" | "unhealthy"; issues: number; }; } > > { const collections = await this.schemaManager.getCollections(); const collectionsWithHealth = []; for (const collection of collections) { try { const health = await this.getCollectionHealth(collection.id); collectionsWithHealth.push({ ...collection, health: { status: health.status, issues: health.dataIntegrity.issues.length + (health.schemaValid ? 0 : 1), }, }); } catch (error) { this.logger.error( `Error getting health for collection ${collection.name}:`, error ); collectionsWithHealth.push({ ...collection, health: { status: "unhealthy" as const, issues: 1, }, }); } } return collectionsWithHealth; } /** * Get all collections for a project * * Retrieves all collections associated with a specific project. * * @param {string} projectId - Project ID * @returns {Promise<Collection[]>} Array of collections * @throws {Error} If query fails * * @example * const collections = await collectionsService.getCollectionsByProject('project-id'); */ async getCollectionsByProject(projectId: string): Promise<Collection[]> { try { const collections = await this.schemaManager.getCollections(); return collections.filter( (collection) => collection.project_id === projectId ); } catch (error) { this.logger.error( `Error getting collections for project ${projectId}:`, error ); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getCollectionsByProject", projectId, }); } } /** * Get collections by project ID * * Alias for getCollectionsByProject. Retrieves all collections for a project. * * @param {string} projectId - Project ID * @returns {Promise<Collection[]>} Array of collections * @throws {Error} If query fails * * @example * const collections = await collectionsService.getProjectCollections('project-id'); */ async getProjectCollections(projectId: string): Promise<Collection[]> { return this.getCollectionsByProject(projectId); } /** * Get a collection by ID or name * * Retrieves a single collection by its ID (UUID) or name (case-insensitive) within a project. * Supports both UUID lookup and case-insensitive name lookup. * * @param {string} projectId - Project ID * @param {string} collectionId - Collection ID (UUID) or name * @returns {Promise<Collection | null>} Collection or null if not found * @throws {Error} If query fails * * @example * const collection = await collectionsService.getCollection('project-id', 'collection-id'); * const collectionByName = await collectionsService.getCollection('project-id', 'test_collection'); */ async getCollection( projectId: string, collectionId: string ): Promise<Collection | null> { try { // Check if collectionId is a UUID const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(collectionId); let result; if (isUUID) { // Lookup by UUID (id field) result = await this.db.query( `SELECT * FROM collections WHERE id = $1 AND project_id = $2`, [collectionId, projectId] ); } else { // Lookup by name (case-insensitive using LOWER for SQLite compatibility) result = await this.db.query( `SELECT * FROM collections WHERE LOWER(name) = LOWER($1) AND project_id = $2`, [collectionId, projectId] ); } if (result.rows.length === 0) { this.logger.warn( `No collection found with ${isUUID ? 'ID' : 'name'} "${collectionId}" in project "${projectId}"` ); return null; } const dbCollection = result.rows[0] as Record<string, unknown>; this.logger.info(`Found collection:`, dbCollection); // Parse JSON fields and indexes from database let fields: FieldDefinition[] = []; let indexes: IndexDefinition[] = []; try { if (typeof dbCollection.fields === 'string') { fields = JSON.parse(dbCollection.fields as string) as FieldDefinition[]; } else if (Array.isArray(dbCollection.fields)) { fields = dbCollection.fields as FieldDefinition[]; } } catch (error) { this.logger.warn('Failed to parse fields:', error); } try { if (typeof dbCollection.indexes === 'string') { indexes = JSON.parse(dbCollection.indexes as string) as IndexDefinition[]; } else if (Array.isArray(dbCollection.indexes)) { indexes = dbCollection.indexes as IndexDefinition[]; } } catch (error) { this.logger.warn('Failed to parse indexes:', error); } // Convert database collection to Collection interface return { id: dbCollection.id as string, name: dbCollection.name as string, description: dbCollection.description as string, project_id: dbCollection.project_id as string, fields, indexes, schema: { fields, indexes, }, settings: (dbCollection.settings as unknown as CollectionSettings) || { read_permissions: [], write_permissions: [], delete_permissions: [], enable_audit_log: false, enable_soft_delete: false, enable_versioning: false, }, created_at: dbCollection.created_at as string, updated_at: dbCollection.updated_at as string, }; } catch (error) { this.logger.error("Failed to get collection", { error, projectId, collectionId, }); throw error; } } /** * Update a collection * * Updates collection metadata (name, description, settings) without changing schema. * * @param {string} projectId - Project ID * @param {string} collectionId - Collection ID * @param {Partial<Collection>} updates - Collection updates * @returns {Promise<Collection>} Updated collection * @throws {Error} If update fails or collection not found * * @example * const updated = await collectionsService.updateCollection('project-id', 'collection-id', { * description: 'Updated description' * }); */ async updateCollection( projectId: string, collectionId: string, updates: Partial<Collection> ): Promise<Collection> { try { const collection = await this.getCollection(projectId, collectionId); if (!collection) { throw KrapiError.notFound("Collection not found", { projectId, collectionId, }); } return await this.schemaManager.updateCollection(collectionId, updates); } catch (error) { this.logger.error("Failed to update collection", { error, projectId, collectionId, updates, }); throw normalizeError(error, "INTERNAL_ERROR", { operation: "updateCollection", projectId, collectionId, }); } } /** * Delete a collection * * Permanently deletes a collection and all its documents. * This action cannot be undone. * * @param {string} projectId - Project ID * @param {string} collectionId - Collection ID * @returns {Promise<boolean>} True if deletion successful * @throws {Error} If deletion fails or collection not found * * @example * const deleted = await collectionsService.deleteCollection('project-id', 'collection-id'); */ async deleteCollection( projectId: string, collectionId: string ): Promise<boolean> { try { const collection = await this.getCollection(projectId, collectionId); if (!collection) { throw KrapiError.notFound("Collection not found", { projectId, collectionId, }); } return await this.schemaManager.deleteCollection(collectionId); } catch (error) { this.logger.error("Failed to delete collection", { error, projectId, collectionId, }); throw error; } } // Private helper methods private async getCollectionByName(name: string): Promise<Collection | null> { const collections = await this.schemaManager.getCollections(); return collections.find((c) => c.name === name) || null; } private async getCollectionByNameInProject( projectId: string, name: string ): Promise<Collection | null> { try { this.logger.info( `Looking for collection "${name}" in project "${projectId}"` ); // Use case-insensitive matching with LOWER for SQLite compatibility const result = await this.db.query( `SELECT * FROM collections WHERE LOWER(name) = LOWER($1) AND project_id = $2`, [name, projectId] ); this.logger.info( `Database query result: ${result.rows.length} rows found` ); if (result.rows.length === 0) { this.logger.warn( `No collection found with name "${name}" in project "${projectId}"` ); return null; } const dbCollection = result.rows[0] as Record<string, unknown>; this.logger.info(`Found collection:`, dbCollection); // Parse JSON fields and indexes from database let fields: FieldDefinition[] = []; let indexes: IndexDefinition[] = []; try { if (typeof dbCollection.fields === 'string') { fields = JSON.parse(dbCollection.fields as string) as FieldDefinition[]; } else if (Array.isArray(dbCollection.fields)) { fields = dbCollection.fields as FieldDefinition[]; } } catch (error) { this.logger.warn('Failed to parse fields:', error); } try { if (typeof dbCollection.indexes === 'string') { indexes = JSON.parse(dbCollection.indexes as string) as IndexDefinition[]; } else if (Array.isArray(dbCollection.indexes)) { indexes = dbCollection.indexes as IndexDefinition[]; } } catch (error) { this.logger.warn('Failed to parse indexes:', error); } // Convert database collection to Collection interface return { id: dbCollection.id as string, name: dbCollection.name as string, description: dbCollection.description as string, project_id: dbCollection.project_id as string, fields, indexes, schema: { fields, indexes, }, settings: (dbCollection.settings as unknown as CollectionSettings) || { read_permissions: [], write_permissions: [], delete_permissions: [], enable_audit_log: false, enable_soft_delete: false, enable_versioning: false, }, created_at: dbCollection.created_at as string, updated_at: dbCollection.updated_at as string, }; } catch (error) { this.logger.error("Error getting collection by name in project:", error); return null; } } private isValidCollectionName(name: string): boolean { return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name); } private validateCollectionFields( fields: Array<{ name: string; type: FieldType }> ): void { if (fields.length === 0) { throw KrapiError.validationError( "Collection must have at least one field", "fields" ); } const fieldNames = new Set<string>(); for (const field of fields) { if (!this.isValidFieldName(field.name)) { throw KrapiError.validationError( `Invalid field name: "${field.name}". Use only letters, numbers, and underscores.`, field.name ); } if (fieldNames.has(field.name)) { throw KrapiError.validationError( `Duplicate field name: "${field.name}"`, field.name ); } fieldNames.add(field.name); } } private isValidFieldName(name: string): boolean { return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name); } private getIssueSeverity(issueType: string): "error" | "warning" | "info" { switch (issueType) { case "missing_field": case "wrong_type": return "error"; case "missing_index": case "missing_constraint": return "warning"; case "extra_field": return "info"; default: return "warning"; } } private generateRecommendations( issues: Array<{ type: string; field?: string; description: string; severity?: string; }>, _collection: Collection ): string[] { const recommendations: string[] = []; if (issues.some((i) => i.type === "missing_field")) { recommendations.push( "Run auto-fix to add missing fields to the database" ); } if (issues.some((i) => i.type === "wrong_type")) { recommendations.push( "Field type mismatches detected. Consider running auto-fix or manually updating field types" ); } if (issues.some((i) => i.type === "missing_index")) { recommendations.push( "Missing indexes detected. Run auto-fix to create them for better performance" ); } if (issues.some((i) => i.type === "extra_field")) { recommendations.push( "Extra fields found in database. Review if they should be removed or added to the schema" ); } if (recommendations.length === 0) { recommendations.push("Collection schema is healthy. No action needed."); } return recommendations; } private determineHealthStatus( schemaValid: boolean, dataIntegrity: { hasNullViolations: boolean; hasUniqueViolations: boolean; hasForeignKeyViolations: boolean; } ): "healthy" | "degraded" | "unhealthy" { if (!schemaValid) { return "unhealthy"; } if ( dataIntegrity.hasNullViolations || dataIntegrity.hasUniqueViolations || dataIntegrity.hasForeignKeyViolations ) { return "degraded"; } return "healthy"; } private getCollectionTemplate(templateName: string): { name: string; description: string; fields: Array<{ name: string; type: FieldType; required?: boolean; unique?: boolean; indexed?: boolean; description?: string; }>; indexes: Array<{ name: string; fields: string[]; unique?: boolean; }>; } | null { const templates: Record< string, { name: string; description: string; fields: Array<{ name: string; type: FieldType; required?: boolean; unique?: boolean; indexed?: boolean; description?: string; }>; indexes: Array<{ name: string; fields: string[]; unique?: boolean; }>; } > = { articles: { name: "articles", description: "Blog articles or news posts", fields: [ { name: "title", type: "string" as FieldType, required: true, indexed: true, description: "Article title", }, { name: "content", type: "text" as FieldType, required: true, description: "Article content", }, { name: "author", type: "string" as FieldType, required: true, indexed: true, description: "Author name", }, { name: "published_at", type: "date" as FieldType, description: "Publication date", }, { name: "tags", type: "array" as FieldType, description: "Article tags", }, ], indexes: [ { name: "idx_articles_title", fields: ["title"], unique: false }, { name: "idx_articles_author", fields: ["author"], unique: false }, { name: "idx_articles_published", fields: ["published_at"], unique: false, }, ], }, products: { name: "products", description: "E-commerce products", fields: [ { name: "name", type: "string" as FieldType, required: true, indexed: true, description: "Product name", }, { name: "description", type: "text" as FieldType, description: "Product description", }, { name: "price", type: "number" as FieldType, required: true, description: "Product price", }, { name: "category", type: "string" as FieldType, required: true, indexed: true, description: "Product category", }, { name: "in_stock", type: "boolean" as FieldType, required: true, description: "Stock availability", }, { name: "images", type: "array" as FieldType, description: "Product images", }, ], indexes: [ { name: "idx_products_name", fields: ["name"], unique: false }, { name: "idx_products_category", fields: ["category"], unique: false, }, { name: "idx_products_price", fields: ["price"], unique: false }, ], }, users: { name: "users", description: "User accounts", fields: [ { name: "email", type: "string" as FieldType, required: true, unique: true, indexed: true, description: "User email", }, { name: "username", type: "string" as FieldType, required: true, unique: true, indexed: true, description: "Username", }, { name: "first_name", type: "string" as FieldType, description: "First name", }, { name: "last_name", type: "string" as FieldType, description: "Last name", }, { name: "is_active", type: "boolean" as FieldType, required: true, description: "Account status", }, { name: "profile_data", type: "json" as FieldType, description: "Additional profile information", }, ], indexes: [ { name: "idx_users_email", fields: ["email"], unique: true }, { name: "idx_users_username", fields: ["username"], unique: true }, ], }, }; return templates[templateName] || null; } // ===== DOCUMENT CRUD OPERATIONS ===== /** * Get all documents from a collection * * Retrieves documents from a collection with optional filtering, sorting, and pagination. * * @param {string} projectId - Project ID * @param {string} collectionName - Collection name * @param {DocumentFilter} [filter] - Document filters * @param {DocumentQueryOptions} [options] - Query options (limit, offset, sort) * @returns {Promise<Document[]>} Array of documents * @throws {Error} If query fails or collection not found * * @example * const documents = await collectionsService.getDocuments('project-id', 'users', { * field_filters: { active: true }, * search: 'john' * }, { limit: 10, sort_by: 'created_at', sort_order: 'desc' }); */ async getDocuments( projectId: string, collectionName: string, filter?: DocumentFilter, options?: DocumentQueryOptions ): Promise<Document[]> { try { const collection = await this.getCollectionByNameInProject( projectId, collectionName ); if (!collection) { throw KrapiError.notFound( `Collection "${collectionName}" not found in project ${projectId}`, { collectionName, projectId } ); } // Include project_id in query for proper database routing let query = `SELECT * FROM documents WHERE collection_id = $1 AND project_id = $2`; const params: unknown[] = [collection.id, projectId]; let paramCount = 2; // Apply filters if (filter) { // Note: Soft delete not implemented in current database schema // if (!filter.include_deleted) { // query += ` AND is_deleted = false`; // } if (filter.field_filters) { for (const [field, value] of Object.entries(filter.field_filters)) { paramCount++; query += ` AND data->>'${field}' = $${paramCount}`; params.push(value); } } if (filter.search) { paramCount++; // SQLite uses LIKE with lower() for case-insensitive search query += ` AND lower(data) LIKE lower($${paramCount})`; params.push(`%${filter.search}%`); } if (filter.created_after) { paramCount++; query += ` AND created_at >= $${paramCount}`; params.push(filter.created_after); } if (filter.created_before) { paramCount++; query += ` AND created_at <= $${paramCount}`; params.push(filter.created_before); } if (filter.updated_after) { paramCount++; query += ` AND updated_at >= $${paramCount}`; params.push(filter.updated_after); } if (filter.updated_before) { paramCount++; query += ` AND updated_at <= $${paramCount}`; params.push(filter.updated_before); } if (filter.created_by) { paramCount++; query += ` AND created_by = $${paramCount}`; params.push(filter.created_by); } if (filter.updated_by) { paramCount++; query += ` AND updated_by = $${paramCount}`; params.push(filter.updated_by); } } // Apply sorting if (options?.sort_by) { const sortOrder = options.sort_order || "asc"; if ( options.sort_by === "created_at" || options.sort_by === "updated_at" ) { query += ` ORDER BY ${options.sort_by} ${sortOrder.toUpperCase()}`; } else { // For numeric fields, cast to appropriate type for proper sorting const field = options.sort_by; if ( field === "priority" || field === "id" || field.includes("count") || field.includes("total") ) { // Use CAST for type conversion query += ` ORDER BY CAST(json_extract(data, '$.${field}') AS INTEGER) ${sortOrder.toUpperCase()}`; } else if ( field === "is_active" || field.includes("enabled") || field.includes("active") ) { // SQLite: Boolean fields are stored as 0/1, cast to integer for sorting query += ` ORDER BY CAST(json_extract(data, '$.${field}') AS INTEGER) ${sortOrder.toUpperCase()}`; } else { query += ` ORDER BY data->>'${field}' ${sortOrder.toUpperCase()}`; } } } else { query += ` ORDER BY created_at DESC`; } // Apply pagination if (options?.limit) { paramCount++; query += ` LIMIT $${paramCount}`; params.push(options.limit); } if (options?.offset) { paramCount++; query += ` OFFSET $${paramCount}`; params.push(options.offset); } const result = await this.db.query(query, params); return result.rows as Document[]; } catch (error) { this.logger.error("Failed to get documents:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getDocuments", projectId, collectionName, }); } } /** * Get a single document by ID * * Retrieves a document by its ID from a collection. * * @param {string} projectId - Project ID * @param {string} collectionName - Collection name * @param {string} documentId - Document ID * @returns {Promise<Document | null>} Document or null if not found * @throws {Error} If query fails or collection not found * * @example * const document = await collectionsService.getDocumentById('project-id', 'users', 'doc-id'); */ async getDocumentById( projectId: string, collectionName: string, documentId: string ): Promise<Document | null> { try { const collection = await this.getCollectionByNameInProject( projectId, collectionName ); if (!collection) { throw KrapiError.notFound( `Collection "${collectionName}" not found in project ${projectId}`, { collectionName, projectId } ); } // Include project_id in query for proper database routing const result = await this.db.query( `SELECT * FROM documents WHERE collection_id = $1 AND id = $2 AND project_id = $3`, [collection.id, documentId, projectId] ); if (result.rows.length === 0) { return null; } // Use mapDocument to parse JSON data and ensure proper formatting const document = this.mapDocument(result.rows[0] as Record<string, unknown>); // Ensure project_id is set from collection document.project_id = collection.project_id || projectId; return document; } catch (error) { this.logger.error("Failed to get document by ID:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getDocumentById", projectId, collectionName, documentId, }); } } /** * Create a new document in a collection * * Creates a new document with validation against the collection schema. * * @param {string} projectId - Project ID * @param {string} collectionName - Collection name * @param {CreateDocumentRequest} documentData - Document data * @param {Record<string, unknown>} documentData.data - Document data object * @param {string} [documentData.created_by] - User ID who created the document * @returns {Promise<Document>} Created document * @throws {Error} If creation fails, validation fails, or collection not found * * @example * const document = await collectionsService.createDocument('project-id', 'users', { * data: { email: 'user@example.com', name: 'John Doe' }, * created_by: 'user-id' * }); */ async createDocument( projectId: string, collectionName: string, documentData: CreateDocumentRequest ): Promise<Document> { try { const collection = await this.getCollectionByNameInProject( projectId, collectionName ); if (!collection) { throw KrapiError.notFound( `Collection "${collectionName}" not found in project ${projectId}`, { collectionName, projectId } ); } // Validate document data against collection schema // Handle the case where data is double-nested due to frontend/backend mismatch const actualDocumentData = (documentData.data?.data || documentData.data) as Record<string, unknown>; await this.validateDocumentData(collection, actualDocumentData); // Generate document ID (SQLite doesn't support RETURNING *) const documentId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; // SQLite-compatible INSERT (no RETURNING *) // Include project_id and updated_by in INSERT for proper database routing const createdBy = documentData.created_by || documentData.data?.created_by || "system"; await this.db.query( `INSERT INTO documents (id, collection_id, project_id, data, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6)`, [ documentId, collection.id, projectId, JSON.stringify(actualDocumentData), createdBy, createdBy, // updated_by defaults to created_by for new documents ] ); // Query back the inserted row (SQLite doesn't support RETURNING *) // Use collection_id and project_id for proper database routing const result = await this.db.query( `SELECT * FROM documents WHERE id = $1 AND collection_id = $2 AND project_id = $3`, [documentId, collection.id, projectId] ); this.logger.info(`Created document in collection "${collectionName}"`); return this.mapDocument(result.rows[0] as Record<string, unknown>); } catch (error) { // Comprehensive error logging with full context // Include input data (what was received) const inputData = { projectId, collectionName, documentData: { // Include document data structure but not full content (may be large) hasData: !!documentData.data, dataKeys: documentData.data ? Object.keys(documentData.data) : [], created_by: documentData.created_by, }, }; logServiceOperationError( this.logger, error, "CollectionsService", "createDocument", inputData, { projectId, collectionName } ); // Preserve validation error messages for proper error handling if (error instanceof Error) { if ( error.message.includes("Required field") || error.message.includes("Invalid type") || error.message.includes("validation") || error.message.includes("missing") ) { // Re-throw validation errors with their original message throw error; } } // For other errors, throw generic message throw normalizeError(error, "INTERNAL_ERROR", { operation: "createDocument", projectId, collectionName, }); } } /** * Update an existing document * * Updates a document's data with validation against the collection schema. * * @param {string} projectId - Project ID * @param {string} collectionName - Collection name * @param {string} documentId - Document ID * @param {UpdateDocumentRequest} updateData - Update data * @param {Record<string, unknown>} updateData.data - Updated data (merged with existing) * @param {string} [updateData.updated_by] - User ID who updated the document * @returns {Promise<Document | null>} Updated document or null if not found * @throws {Error} If update fails, validation fails, or document not found * * @example * const updated = await collectionsService.updateDocument('project-id', 'users', 'doc-id', { * data: { name: 'Jane Doe' }, * updated_by: 'user-id' * }); */ async updateDocument( projectId: string, collectionName: string, documentId: string, updateData: UpdateDocumentRequest ): Promise