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