UNPKG

@wearesage/schema

Version:

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

277 lines (236 loc) 10 kB
import "reflect-metadata"; import { MetadataLayerAdapter, PostgresMetadata } from "../../adapters/metadata-layer"; import { Neo4jAdapter } from "../../adapters/neo4j"; import { PostgreSQLAdapter } from "../../adapters/postgresql"; import { MetadataRegistry } from "../../core/MetadataRegistry"; import { SchemaBuilder } from "../../core/SchemaBuilder"; import { RealConversation } from "../fixtures/RealConversation"; import { RealMessage } from "../fixtures/RealMessage"; describe('MetadataLayerAdapter Integration', () => { let neo4jAdapter: Neo4jAdapter; let postgresAdapter: PostgreSQLAdapter; let registry: MetadataRegistry; let metadataAdapter: MetadataLayerAdapter; const testConversationId = `test-conv-${Date.now()}`; const testMessageId = `test-msg-${Date.now()}`; beforeAll(async () => { // Create registry and register entities registry = new MetadataRegistry(); const builder = new SchemaBuilder(registry); builder.registerEntities([RealConversation, RealMessage]); // Create real database adapters neo4jAdapter = new Neo4jAdapter(registry, "bolt://127.0.0.1:7687", { username: "neo4j", password: "password" }); postgresAdapter = new PostgreSQLAdapter(registry, "postgres://postgres:password@localhost:5432/test_metadata"); // Create the metadata layer adapter metadataAdapter = new MetadataLayerAdapter(neo4jAdapter, postgresAdapter, registry); // Try to connect - skip tests if databases not available try { await neo4jAdapter.runNativeQuery("RETURN 1"); await postgresAdapter.runNativeQuery("SELECT 1"); // Drop and recreate metadata table to ensure clean state await postgresAdapter.runNativeQuery(`DROP TABLE IF EXISTS realconversations`); await postgresAdapter.runNativeQuery(` CREATE TABLE realconversations ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), messagecount INTEGER, lastmessageat TIMESTAMP, model VARCHAR(255), createdat TIMESTAMP ) `); console.log("✅ Databases connected and metadata table created"); } catch (error) { console.log("⚠️ Skipping integration tests - databases not available"); console.log(" Start Neo4j on bolt://localhost:7687 and Postgres on localhost:5432"); return; } // Clean up any existing test data try { await neo4jAdapter.runNativeQuery(` MATCH (c:Conversation {id: $id})-[:CONTAINS]->(m:Message) DETACH DELETE c, m `, { id: testConversationId }); await postgresAdapter.runNativeQuery(` DELETE FROM realconversations WHERE id = $1 `, [testConversationId]); } catch (error) { // Ignore cleanup errors } }); afterAll(async () => { // Clean up test data try { await neo4jAdapter.runNativeQuery(` MATCH (c:Conversation {id: $id})-[:CONTAINS]->(m:Message) DETACH DELETE c, m `, { id: testConversationId }); await postgresAdapter.runNativeQuery(` DELETE FROM realconversations WHERE id = $1 `, [testConversationId]); } catch (error) { // Ignore cleanup errors } // Close connections try { if (typeof (neo4jAdapter as any).close === 'function') { await (neo4jAdapter as any).close(); } if (typeof (postgresAdapter as any).close === 'function') { await (postgresAdapter as any).close(); } } catch (error) { // Ignore close errors } }); beforeEach(async () => { // Skip if databases not available try { await neo4jAdapter.runNativeQuery("RETURN 1"); await postgresAdapter.runNativeQuery("SELECT 1"); } catch (error) { console.log("⚠️ Skipping test - databases not available"); return; } }); it('should save conversation to Neo4j and sync metadata to Postgres', async () => { const conversation = new RealConversation(); conversation.id = testConversationId; conversation.name = 'Integration Test Chat'; conversation.messageCount = 3; conversation.lastMessageAt = new Date(); conversation.model = 'gemma3:12b-it-qat'; conversation.systemPrompt = 'You are a helpful assistant'; // Rich field conversation.temperature = 0.7; // Rich field conversation.createdAt = new Date(); // Save via metadata layer adapter await metadataAdapter.save(conversation); // Verify data in Neo4j (should have ALL fields) const neo4jResult = await neo4jAdapter.query(RealConversation, { id: testConversationId }); expect(neo4jResult).toBeTruthy(); expect(neo4jResult).toMatchObject({ id: testConversationId, name: 'Integration Test Chat', messageCount: 3, model: 'gemma3:12b-it-qat', systemPrompt: 'You are a helpful assistant', temperature: 0.7 }); // Verify metadata in Postgres using raw SQL (bypassing entity queries) const pgRawResult = await postgresAdapter.runNativeQuery( 'SELECT * FROM realconversations WHERE id = $1', [testConversationId] ) as { rows: any[], rowCount: number }; expect(pgRawResult).toBeTruthy(); console.log('🔫 RAW PG RESULT:', pgRawResult); // Verify the data structure (Postgres query results have rows array) const pgData = pgRawResult.rows && pgRawResult.rows.length > 0 ? pgRawResult.rows[0] : null; expect(pgData).toBeTruthy(); expect(pgData).toMatchObject({ id: testConversationId, name: 'Integration Test Chat', messagecount: 3, // Note: Postgres lowercases column names model: 'gemma3:12b-it-qat' }); }); it('should route simple queries to Postgres for speed', async () => { // Query using only metadata fields - should hit Postgres const result = await metadataAdapter.query(RealConversation, { messageCount: 3 }); expect(result).toBeTruthy(); expect(result).toMatchObject({ id: testConversationId, messageCount: 3 }); }); it('should route complex queries to Neo4j for rich data', async () => { // Clean up any existing conversations with this systemPrompt first await neo4jAdapter.runNativeQuery(` MATCH (c:Conversation {systemPrompt: $systemPrompt}) DETACH DELETE c `, { systemPrompt: 'You are a helpful assistant' }); // Create fresh conversation for this test const conversation = new RealConversation(); conversation.id = testConversationId; conversation.name = 'Integration Test Chat'; conversation.messageCount = 3; conversation.lastMessageAt = new Date(); conversation.model = 'gemma3:12b-it-qat'; conversation.systemPrompt = 'You are a helpful assistant'; conversation.temperature = 0.7; conversation.createdAt = new Date(); await metadataAdapter.save(conversation); // Query using rich field - should hit Neo4j const result = await metadataAdapter.query(RealConversation, { systemPrompt: 'You are a helpful assistant' }); expect(result).toBeTruthy(); expect(result).toMatchObject({ id: testConversationId, systemPrompt: 'You are a helpful assistant', temperature: 0.7 }); }); it('should handle queryMany operations efficiently', async () => { // Create additional test conversation const conversation2 = new RealConversation(); conversation2.id = testConversationId + '_2'; conversation2.name = 'Second Test Chat'; conversation2.messageCount = 5; conversation2.model = 'gemma3:12b-it-qat'; conversation2.createdAt = new Date(); await metadataAdapter.save(conversation2); // Query multiple conversations by model (metadata field) - should hit Postgres const results = await metadataAdapter.queryMany(RealConversation, { model: 'gemma3:12b-it-qat' }); expect(results).toHaveLength(2); expect(results.map(r => r.messageCount).sort()).toEqual([3, 5]); // Cleanup await metadataAdapter.delete(RealConversation, conversation2.id); }); it('should delete from both databases atomically', async () => { // Verify entity exists in both databases const neo4jBefore = await neo4jAdapter.query(RealConversation, { id: testConversationId }); const pgBefore = await postgresAdapter.query(RealConversation, { id: testConversationId }); expect(neo4jBefore).toBeTruthy(); expect(pgBefore).toBeTruthy(); // Delete via metadata layer await metadataAdapter.delete(RealConversation, testConversationId); // Verify entity is gone from both databases const neo4jAfter = await neo4jAdapter.query(RealConversation, { id: testConversationId }); const pgAfter = await postgresAdapter.query(RealConversation, { id: testConversationId }); expect(neo4jAfter).toBeNull(); expect(pgAfter).toBeNull(); }); it('should handle entities without metadata config normally', async () => { const message = new RealMessage(); message.id = testMessageId; message.content = 'Hello world!'; message.role = 'user'; message.thinking = 'User greeting'; message.createdAt = new Date(); // Save message (no metadata config) - should only go to primary (Neo4j) await metadataAdapter.save(message); // Query should go to primary adapter const result = await metadataAdapter.query(RealMessage, { id: testMessageId }); expect(result).toBeTruthy(); expect(result).toMatchObject({ id: testMessageId, content: 'Hello world!', thinking: 'User greeting' }); // Cleanup await metadataAdapter.delete(RealMessage, testMessageId); }); it('should route native queries to primary adapter', async () => { // Run a Cypher query const result = await metadataAdapter.runNativeQuery(` MATCH (c:Conversation {id: $id}) RETURN c.name as name, c.messageCount as count `, { id: testConversationId }); expect(result).toBeTruthy(); }); });