@wearesage/schema
Version:
A flexible schema definition and validation system for TypeScript with multi-database support
344 lines (293 loc) • 10.7 kB
text/typescript
/**
* 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();