UNPKG

@wearesage/schema

Version:

A flexible schema definition and validation system for TypeScript with multi-database support

344 lines (293 loc) 10.7 kB
/** * Universal Entity Service * ONE service that handles ALL entities using decorator metadata at runtime * This is the core orchestration layer for the schema package */ import "reflect-metadata"; import { DatabaseAdapter } from '../adapters/interface'; import { Repository } from '../adapters/repository'; import { Neo4jAdapter } from '../adapters/neo4j'; import { MetadataLayerAdapter } from '../adapters/metadata-layer'; import { PostgreSQLAdapter } from '../adapters/postgresql'; import { MetadataRegistry } from '../core/MetadataRegistry'; import { SchemaBuilder } from '../core/SchemaBuilder'; import { SchemaReflector } from '../core/SchemaReflector'; import { PropertyMetadata, RelationshipMetadata } from '../core/types'; export interface EntityContext { user: { id: string; username?: string; email?: string; role: number; permissions?: string[]; }; requestId?: string; } export interface EntityMetadata { auth: any; properties: Map<string, PropertyMetadata>; relationships: Map<string, RelationshipMetadata>; } // Re-export for convenience export { PropertyMetadata, RelationshipMetadata } from '../core/types'; export class UniversalEntityService { private static instance: UniversalEntityService; private adapter!: DatabaseAdapter; private repositories: Map<Function, Repository<any>> = new Map(); private registry: MetadataRegistry; private reflector: SchemaReflector; private constructor() { // Initialize registry and reflector this.registry = new MetadataRegistry(); this.reflector = new SchemaReflector(this.registry); // Initialize default adapter (Neo4j) this.initializeAdapter(); } public static getInstance(): UniversalEntityService { if (!UniversalEntityService.instance) { UniversalEntityService.instance = new UniversalEntityService(); } return UniversalEntityService.instance; } /** * Register entities with the service * This extracts decorator metadata and makes entities available for CRUD operations */ public registerEntities(entityClasses: Array<new (...args: any[]) => any>): void { const builder = new SchemaBuilder(this.registry); builder.registerEntities(entityClasses); console.log(`✅ Registered ${entityClasses.length} entities with UniversalEntityService`); } /** * Initialize the database adapter with metadata layer */ private initializeAdapter(): void { try { // Initialize Neo4j as primary adapter const connectionString = process.env.NEO4J_URL || process.env.NEO4J_URI || 'bolt://127.0.0.1:7687'; const auth = { username: process.env.NEO4J_USER || 'neo4j', password: process.env.NEO4J_PASSWORD || 'password' }; const neo4jAdapter = new Neo4jAdapter(this.registry, connectionString, auth); // Initialize PostgreSQL as metadata adapter const pgConnectionString = process.env.DATABASE_URL || 'postgresql://zach@localhost:5432/sage'; const postgresAdapter = new PostgreSQLAdapter(this.registry, pgConnectionString); // Wrap with metadata layer for embeddings and fast lookups this.adapter = new MetadataLayerAdapter(neo4jAdapter, postgresAdapter, this.registry); console.log('✅ MetadataLayerAdapter initialized (Neo4j + PostgreSQL + Embeddings)'); } catch (error) { console.error('Failed to initialize MetadataLayerAdapter:', error); throw error; } } /** * Create entity with permission checks and validation */ async create<T extends object>( EntityClass: new () => T, data: Partial<T>, context: EntityContext ): Promise<T> { // 1. Get entity metadata const metadata = this.getEntityMetadata(EntityClass); // 2. Check permissions this.checkPermissions(metadata.auth, context.user); // 3. Validate required fields this.validateRequiredFields(metadata.properties, data); // 4. Set timestamps and defaults const processedData = this.processEntityData(data, metadata, 'create'); // 5. Get or create repository const repository = this.getRepository(EntityClass); // 6. Create the entity instance const entity = new EntityClass(); Object.assign(entity, processedData); // 7. Save the entity await repository.save(entity); return entity; } /** * Find entity by ID with permission checks */ async findById<T extends object>( EntityClass: new () => T, id: string, context: EntityContext ): Promise<T | null> { const metadata = this.getEntityMetadata(EntityClass); this.checkPermissions(metadata.auth, context.user); const repository = this.getRepository(EntityClass); return await repository.findById(id); } /** * Find all entities with optional filter */ async findAll<T extends object>( EntityClass: new () => T, filter: any = {}, context: EntityContext ): Promise<T[]> { const metadata = this.getEntityMetadata(EntityClass); this.checkPermissions(metadata.auth, context.user); const repository = this.getRepository(EntityClass); return await repository.find(filter); } /** * Update entity with permission checks */ async update<T extends object>( EntityClass: new () => T, id: string, updates: Partial<T>, context: EntityContext ): Promise<T | null> { const metadata = this.getEntityMetadata(EntityClass); this.checkPermissions(metadata.auth, context.user); // Process updates (timestamps, etc.) const processedUpdates = this.processEntityData(updates, metadata, 'update'); const repository = this.getRepository(EntityClass); // First get the existing entity const existing = await repository.findById(id); if (!existing) { return null; } // Merge updates with existing entity Object.assign(existing, processedUpdates); await repository.save(existing); return existing; } /** * Delete entity with permission checks */ async delete<T extends object>( EntityClass: new () => T, id: string, context: EntityContext ): Promise<boolean> { const metadata = this.getEntityMetadata(EntityClass); this.checkPermissions(metadata.auth, context.user); const repository = this.getRepository(EntityClass); // First check if entity exists const existing = await repository.findById(id); if (!existing) { return false; } try { await repository.delete(id); return true; } catch (error) { return false; } } /** * Load related entities */ async loadRelated<T extends object, R extends object>( EntityClass: new () => T, id: string, relationshipName: string, context: EntityContext ): Promise<R[]> { const metadata = this.getEntityMetadata(EntityClass); this.checkPermissions(metadata.auth, context.user); const relationship = metadata.relationships.get(relationshipName); if (!relationship) { throw new Error(`Relationship '${relationshipName}' not found on ${EntityClass.name}`); } // Note: This would use the database adapter's relationship loading // For now, placeholder implementation throw new Error('Relationship loading not yet implemented'); } /** * Extract entity metadata using SchemaReflector */ private getEntityMetadata(EntityClass: Function): EntityMetadata { try { const schema = this.reflector.getEntitySchema(EntityClass); // Convert schema format to our EntityMetadata format return { auth: schema.auth || {}, properties: new Map(Object.entries(schema.properties || {})), relationships: new Map(Object.entries(schema.relationships || {})) }; } catch (error) { console.error(`Failed to get metadata for ${EntityClass.name}:`, (error as Error).message); throw error; } } /** * Check permissions based on @Auth decorator */ private checkPermissions(authConfig: any, user: EntityContext['user']): void { if (!authConfig) return; // Check role-based permissions if (authConfig.roles && authConfig.roles.length > 0) { const hasRequiredRole = authConfig.roles.some((role: string) => { if (role === 'admin' && user.role >= 999) return true; if (role === 'user' && user.role >= 0 && user.role < 999) return true; return false; }); if (!hasRequiredRole) { throw new Error('Insufficient role permissions'); } } // Check permission-based access if (authConfig.permissions && authConfig.permissions.length > 0) { const userPermissions = user.permissions || []; const hasRequiredPermission = authConfig.permissions.some((perm: string) => userPermissions.includes(perm) ); if (!hasRequiredPermission) { throw new Error('Insufficient permissions'); } } } /** * Validate required fields from @Property decorators */ private validateRequiredFields(properties: Map<string, PropertyMetadata>, data: any): void { for (const [key, metadata] of properties.entries()) { // Skip validation for ID fields since we auto-generate them if (key === 'id') continue; if (metadata.required && (data[key] === undefined || data[key] === null)) { throw new Error(`Required field '${key}' is missing`); } } } /** * Process entity data (add timestamps, defaults, etc.) */ private processEntityData(data: any, metadata: EntityMetadata, operation: 'create' | 'update'): any { const processed = { ...data }; // Add timestamps if (operation === 'create') { processed.createdAt = new Date(); processed.updatedAt = new Date(); // Generate ID if not provided if (!processed.id) { processed.id = this.generateId(); } } else { processed.updatedAt = new Date(); } return processed; } /** * Generate a unique ID */ private generateId(): string { return `entity_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Get or create repository for entity */ private getRepository<T extends object>(EntityClass: new () => T): Repository<T> { if (!this.repositories.has(EntityClass)) { const repository = new Repository(EntityClass, this.adapter, this.registry); this.repositories.set(EntityClass, repository); } return this.repositories.get(EntityClass)!; } } // Export singleton instance export const universalEntityService = UniversalEntityService.getInstance();