@wearesage/schema
Version:
A flexible schema definition and validation system for TypeScript with multi-database support
205 lines (169 loc) • 6.59 kB
text/typescript
import { DatabaseAdapter } from './interface';
import { MetadataRegistry } from '../core/MetadataRegistry';
import { Type } from '../core/types';
import { embeddingService } from './embeddings';
/**
* Configuration for metadata layer fields
*/
export interface MetadataConfig {
fields: string[];
tableName?: string;
autoSync?: boolean;
}
/**
* Hybrid adapter that combines a primary adapter (for rich data/relationships)
* with a metadata adapter (for fast lookups/indexing)
*/
export class MetadataLayerAdapter implements DatabaseAdapter {
readonly type: string = 'metadata-layer';
constructor(
private primaryAdapter: DatabaseAdapter, // e.g., Neo4j for rich graph data
private metadataAdapter: DatabaseAdapter, // e.g., Postgres for fast lookups
private registry: MetadataRegistry
) {}
/**
* Save entity to primary adapter, then sync metadata to secondary
*/
async save<T extends object>(entity: T): Promise<void> {
// Generate embeddings if configured
try {
await embeddingService.generateEmbeddings(entity);
console.log('🧠 Generated embeddings for entity');
} catch (error) {
console.warn('⚠️ Failed to generate embeddings:', error);
// Continue with save even if embeddings fail
}
// Save to primary adapter first (Neo4j)
await this.primaryAdapter.save(entity);
// Extract and sync metadata to secondary adapter (Postgres)
await this.syncMetadata(entity);
}
/**
* Route queries based on complexity and metadata configuration
*/
async query<T>(entityType: Type<T>, criteria: object): Promise<T | null> {
const metadataConfig = this.getMetadataConfig(entityType);
// If no metadata config, use primary adapter
if (!metadataConfig) {
return this.primaryAdapter.query(entityType, criteria);
}
// If this is a simple lookup query (only uses metadata fields), use metadata adapter
if (this.isSimpleQuery(criteria, metadataConfig.fields)) {
const metadataResult = await this.metadataAdapter.query(entityType, criteria);
// If we need full entity, hydrate from primary using the ID
if (metadataResult && this.needsHydration(criteria)) {
return this.primaryAdapter.query(entityType, { id: (metadataResult as any).id });
}
return metadataResult;
}
// Complex queries go to primary adapter
return this.primaryAdapter.query(entityType, criteria);
}
/**
* Route queryMany based on complexity
*/
async queryMany<T>(entityType: Type<T>, criteria: object): Promise<T[]> {
const metadataConfig = this.getMetadataConfig(entityType);
// If no metadata config, use primary adapter
if (!metadataConfig) {
return this.primaryAdapter.queryMany(entityType, criteria);
}
// Simple queries hit metadata layer for speed
if (this.isSimpleQuery(criteria, metadataConfig.fields)) {
return this.metadataAdapter.queryMany(entityType, criteria);
}
// Complex queries go to primary
return this.primaryAdapter.queryMany(entityType, criteria);
}
/**
* Delete from both adapters (if metadata config exists)
*/
async delete<T>(entityType: Type<T>, id: string | number): Promise<void> {
const metadataConfig = this.getMetadataConfig(entityType);
if (metadataConfig) {
// Delete from both adapters if metadata sync is configured
await Promise.all([
this.primaryAdapter.delete(entityType, id),
this.metadataAdapter.delete(entityType, id)
]);
} else {
// Only delete from primary adapter if no metadata config
await this.primaryAdapter.delete(entityType, id);
}
}
/**
* Run native query on primary adapter (for complex operations)
*/
async runNativeQuery<T>(query: string, params?: any): Promise<T> {
return this.primaryAdapter.runNativeQuery(query, params);
}
/**
* Sync metadata fields to secondary adapter
*/
private async syncMetadata<T extends object>(entity: T): Promise<void> {
const entityType = entity.constructor as Type<T>;
const metadataConfig = this.getMetadataConfig(entityType);
if (!metadataConfig) {
return; // No metadata config, skip sync
}
// Extract only the configured metadata fields
const metadataFields = this.extractMetadataFields(entity, metadataConfig.fields);
// Create a minimal metadata entity instance that bypasses validation
const metadataEntity = Object.create(entityType.prototype);
Object.assign(metadataEntity, metadataFields);
// Use the adapter's save method - let it handle the database-specific logic
await this.metadataAdapter.save(metadataEntity);
}
/**
* Extract specified fields from entity for metadata storage
*/
private extractMetadataFields<T extends object>(entity: T, fields: string[]): Partial<T> {
const metadata: any = {};
for (const field of fields) {
if (field in entity) {
metadata[field] = (entity as any)[field];
}
}
return metadata;
}
/**
* Check if query only uses metadata fields (can be routed to fast adapter)
*/
private isSimpleQuery(criteria: object, metadataFields?: string[]): boolean {
if (!metadataFields) {
return false;
}
const queryFields = Object.keys(criteria);
// All query fields must be in metadata fields
return queryFields.every(field => metadataFields.includes(field));
}
/**
* Check if result needs hydration from primary adapter
*/
private needsHydration(criteria: object): boolean {
// For now, simple heuristic: if criteria includes relationships or complex fields
// In the future, this could be more sophisticated
const complexFields = ['include', 'relations', 'with'];
return complexFields.some(field => field in criteria);
}
/**
* Get metadata configuration for entity type
*/
private getMetadataConfig(entityType: Type<any>): MetadataConfig | undefined {
return Reflect.getMetadata('postgres:metadata', entityType);
}
}
/**
* Decorator for configuring PostgreSQL metadata layer
*/
export function PostgresMetadata(config?: Partial<MetadataConfig>) {
return function(target: any) {
const defaultConfig: MetadataConfig = {
fields: ['id', 'name', 'createdAt', 'updatedAt'],
tableName: target.name.toLowerCase() + 's',
autoSync: true
};
const finalConfig = { ...defaultConfig, ...config };
Reflect.defineMetadata('postgres:metadata', finalConfig, target);
};
}