UNPKG

@markwylde/eventbase

Version:

A distributed, event-sourced, key-value database built on top of **NATS JetStream**. Eventbase provides a simple yet powerful API for storing, retrieving, and subscribing to data changes, with automatic state synchronization across distributed instances a

474 lines (402 loc) 15.9 kB
import { test, describe, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import createEventbase from '../src/index.js'; import { setupNats } from '../src/nats.js'; import { StatsEvent } from '../src/types.js'; describe('Eventbase with Stats', async () => { let eventbase1; let eventbase2; let statsEvents: StatsEvent[] = []; let statsSubscription; let statsNats; let streamName: string; let statsStreamName: string; beforeEach(async () => { streamName = `test-${Date.now()}-${Math.random().toString(36).substring(7)}`; statsStreamName = streamName + '-stats'; // Set up stats NATS connection and subscription statsNats = await setupNats(statsStreamName, { servers: ['localhost:4222'], user: 'a', pass: 'a', }); // Subscribe to the stats stream const sub = statsNats.nc.subscribe(`${statsStreamName}.stats`); statsSubscription = (async () => { for await (const msg of sub) { const statsEvent: StatsEvent = JSON.parse(msg.string()); statsEvents.push(statsEvent); } })(); [eventbase1, eventbase2] = await Promise.all([ createEventbase({ dbPath: './test-data/' + streamName + '-node1', nats: { servers: ['localhost:4222'], user: 'a', pass: 'a', }, streamName, statsStreamName, }), createEventbase({ dbPath: './test-data/' + streamName + '-node2', nats: { servers: ['localhost:4222'], user: 'a', pass: 'a', }, streamName, statsStreamName, }), ]); }); afterEach(async () => { await Promise.all([eventbase1.close(), eventbase2.close()]); // Close stats NATS connection await statsNats.close(); // Clear stats events and ensure subscription is cleaned up statsEvents = []; // We don't need to manually stop statsSubscription; it will end when nc is closed }); test('should publish stats events for get operation', async () => { await eventbase1.get('nonexistent'); // Wait a bit to ensure stats event is received await new Promise((resolve) => setTimeout(resolve, 100)); assert.equal(statsEvents.length, 1); const statsEvent = statsEvents[0]; assert.equal(statsEvent.operation, 'GET'); assert.equal(statsEvent.id, 'nonexistent'); assert.ok(typeof statsEvent.timestamp === 'number'); assert.ok(typeof statsEvent.duration === 'number'); }); test('should publish stats events for put operation', async () => { await eventbase1.put('key1', { value: 123 }); await new Promise((resolve) => setTimeout(resolve, 100)); assert.equal(statsEvents.length, 1); const statsEvent = statsEvents[0]; assert.equal(statsEvent.operation, 'PUT'); assert.equal(statsEvent.id, 'key1'); }); test('should publish stats events for delete operation', async () => { await eventbase1.put('keyToDelete', { value: 456 }); statsEvents = []; // Clear previous stats events await eventbase1.delete('keyToDelete'); await new Promise((resolve) => setTimeout(resolve, 100)); assert.equal(statsEvents.length, 1); const statsEvent = statsEvents[0]; assert.equal(statsEvent.operation, 'DELETE'); assert.equal(statsEvent.id, 'keyToDelete'); }); test('should publish stats events for keys operation', async () => { await eventbase1.put('key1', {}); await eventbase1.put('key2', {}); statsEvents = []; // Clear previous stats events await eventbase1.keys('key*'); await new Promise((resolve) => setTimeout(resolve, 100)); assert.equal(statsEvents.length, 1); const statsEvent = statsEvents[0]; assert.equal(statsEvent.operation, 'KEYS'); assert.equal(statsEvent.pattern, 'key*'); }); test('should publish stats events for subscribe operation', async () => { const subscription = eventbase1.subscribe({ key: { $regex: 'key.*' } }, () => {}); await new Promise((resolve) => setTimeout(resolve, 100)); assert.equal(statsEvents.length, 1); const statsEvent = statsEvents[0]; assert.equal(statsEvent.operation, 'SUBSCRIBE'); assert.equal(statsEvent.pattern, JSON.stringify({ key: { $regex: 'key.*' } })); // Cleanup subscription(); }); test('should publish stats events for subscribe emit', async () => { const updates = []; const subscription = eventbase1.subscribe({ value: 'test' }, (key, data) => { updates.push({ key, data }); }); statsEvents = []; await eventbase1.put('key1', { value: 'test' }); await new Promise((resolve) => setTimeout(resolve, 100)); // Expecting 3 stats events: one for PUT one for SUBSCRIBE_EMIT and one for SUBSCRIBE assert.equal(statsEvents.length, 3); const subscribeEmitEvent = statsEvents.find((e) => e.operation === 'SUBSCRIBE_EMIT'); assert.ok(subscribeEmitEvent); assert.equal(subscribeEmitEvent.id, 'key1'); assert.equal(subscribeEmitEvent.pattern, JSON.stringify({ value: 'test' })); // Cleanup subscription(); }); test('should publish stats events for query operation with query details', async () => { // Setup some test data await eventbase1.put('person1', { firstName: 'Joe', lastName: 'Smith' }); await eventbase1.put('person2', { firstName: 'Joe', lastName: 'Brown' }); await eventbase1.put('person3', { firstName: 'Jane', lastName: 'Doe' }); // Clear previous stats events statsEvents = []; // Perform the query const queryObject = { firstName: { $eq: 'Joe' } }; const result = await eventbase1.query(queryObject); // Wait for stats event to be published await new Promise((resolve) => setTimeout(resolve, 100)); // Verify stats event assert.equal(statsEvents.length, 1); const statsEvent = statsEvents[0]; assert.equal(statsEvent.operation, 'QUERY'); assert.deepEqual(statsEvent.query, queryObject); assert.equal(statsEvent.queryResultCount, 2); assert.ok(typeof statsEvent.timestamp === 'number'); assert.ok(typeof statsEvent.duration === 'number'); }); test('should store and retrieve data with metadata', async () => { const testData = { name: 'John Doe', age: 30 }; await eventbase1.put('user1', testData); const result = await eventbase1.get('user1'); assert.deepEqual(result.data, { id: result.data.id, ...testData }); assert.equal(typeof result.meta.dateCreated, 'string'); assert.equal(typeof result.meta.dateModified, 'string'); assert.equal(result.meta.changes, 1); }); test('should resume from stored data', async () => { const localStreamName = `test-${Date.now()}-${Math.random().toString(36).substring(7)}`; const localEventbase1 = await createEventbase({ dbPath: './test-data/' + localStreamName, nats: { servers: ['localhost:4222'], user: 'a', pass: 'a', }, streamName: localStreamName, }); await localEventbase1.put('user1', { name: 'John One', age: 10 }); await localEventbase1.put('user2', { name: 'John Two', age: 20 }); await localEventbase1.put('user3', { name: 'John Three', age: 30 }); await localEventbase1.close(); const messages = []; const localEventbase2 = await createEventbase({ dbPath: './test-data/' + localStreamName, nats: { servers: ['localhost:4222'], user: 'a', pass: 'a', }, streamName: localStreamName, onMessage: (message) => { messages.push(message); }, }); const user2 = await localEventbase2.get('user2'); await localEventbase2.put('user4', { name: 'John Four', age: 40 }); await localEventbase2.close(); assert.equal(messages.length, 1); assert.deepEqual(user2.data, { id: 'user2', name: 'John Two', age: 20 }); }); test('should return null for non-existent keys', async () => { const result = await eventbase1.get('nonexistent'); assert.equal(result, null); }); test('should delete data', async () => { await eventbase1.put('user2', { name: 'Jane Doe' }); await eventbase1.delete('user2'); const result = await eventbase1.get('user2'); assert.equal(result, null); }); test('should sync data between instances', async () => { // Store data in first instance const testData = { name: 'John Doe', age: 30 }; await eventbase1.put('user3', testData); // Verify data in second instance const result = await eventbase2.get('user3'); assert.deepEqual(result.data, { id: 'user3', ...testData }); }); test('should handle concurrent operations', async () => { const operations = []; const expectedResults = new Map(); for (let i = 0; i < 10; i++) { operations.push( eventbase1.put(`key${i}`, { value: i }) .then(result => ({ success: true, key: `key${i}`, result })) .catch(error => ({ success: false, key: `key${i}`, error })) ); expectedResults.set(`key${i}`, { id: 'key' + i, value: i }); } const putResults = await Promise.all(operations); // Give processes more time to complete await new Promise((resolve) => setTimeout(resolve, 1000)); // Collect all results const getOperations = Array.from(expectedResults.keys()).map(key => eventbase1.get(key) .then(result => ({ success: true, key, result })) .catch(error => ({ success: false, key, error })) ); const results = await Promise.all(getOperations); // Verify all operations succeeded without considering order const actualResults = new Map(); results.forEach(result => { if (result.success && result.result && result.result.data) { actualResults.set(result.key, result.result.data); } else { console.warn(`Unexpected result for key ${result.key}:`, result); } }); assert.equal(actualResults.size, expectedResults.size, 'Number of results should match'); for (const [key, expectedValue] of expectedResults) { if (actualResults.has(key)) { assert.deepEqual(actualResults.get(key), expectedValue, `Value for key ${key} should match`); } else { console.warn(`Key ${key} was not successfully retrieved`); } } }); test('should handle updates to existing keys', async () => { const key = 'updateTest'; await eventbase1.put(key, { version: 1 }); await eventbase1.put(key, { version: 2 }); const result = await eventbase1.get(key); assert.deepEqual(result.data, { id: 'updateTest', version: 2 }); assert.equal(result.meta.changes, 2); }); test('should handle large data', async () => { const largeData = { id: 'largeKey', array: Array(100) .fill('') .map((_, i) => ({ id: i, data: 'test'.repeat(50) })), }; await eventbase1.put('largeKey', largeData); const result = await eventbase1.get('largeKey'); assert.deepEqual(result.data, largeData); }); test('should handle special characters in keys', async () => { const specialKey = '!@#$%^&*()_+'; const testData = { test: true }; await eventbase1.put(specialKey, testData); const result = await eventbase1.get(specialKey); assert.deepEqual(result.data, { id: specialKey, ...testData }); }); test('should filter keys based on pattern', async () => { await eventbase1.put('something:1', { value: 1 }); await eventbase1.put('something:2', { value: 2 }); await eventbase1.put('other:1', { value: 3 }); const keys = await eventbase1.keys('something:*'); assert.deepEqual(keys.sort(), ['something:1', 'something:2'].sort()); }); test('should subscribe to events and receive updates', async () => { const updates = []; const unsubscribe = eventbase1.subscribe({ id: { $regex: 'test:.*' } }, (key, data, meta, event) => { updates.push({ key, data, meta, event }); }); await eventbase1.put('test:1', { id: 'test:1', value: 1 }); await eventbase1.put('test:2', { id: 'test:2', value: 2 }); const expectedUpdates = [ { key: 'test:1', data: { id: 'test:1', value: 1 }, meta: { id: 'test:1', changes: 1, dateCreated: updates[0]?.meta?.dateCreated || 'FAILED', dateModified: updates[0]?.meta?.dateModified || 'FAILED', }, event: { oldData: null, data: { id: 'test:1', value: 1 }, id: 'test:1', type: 'PUT', timestamp: updates[0]?.event?.timestamp, }, }, { key: 'test:2', data: { id: 'test:2', value: 2 }, meta: { id: 'test:2', changes: 1, dateCreated: updates[1]?.meta?.dateCreated || 'FAILED', dateModified: updates[1]?.meta?.dateModified || 'FAILED', }, event: { oldData: null, data: { id: 'test:2', value: 2 }, id: 'test:2', type: 'PUT', timestamp: updates[1]?.event?.timestamp, }, }, ]; assert.deepEqual(updates, expectedUpdates); unsubscribe(); }); test('should handle multiple subscriptions', async () => { const updates1 = []; const updates2 = []; const unsubscribe1 = eventbase1.subscribe({ id: 'multi:1' }, (key, data) => { updates1.push({ key, value: data }); }); const unsubscribe2 = eventbase1.subscribe({ id: 'multi:2' }, (key, data) => { updates2.push({ key, value: data }); }); await eventbase1.put('multi:1', { value: 1 }); await eventbase1.put('multi:2', { value: 2 }); assert.deepEqual(updates1, [{ key: 'multi:1', value: { id: 'multi:1', value: 1 } }]); assert.deepEqual(updates2, [{ key: 'multi:2', value: { id: 'multi:2', value: 2 } }]); unsubscribe1(); unsubscribe2(); }); test('should unsubscribe from events', async () => { const updates = []; const unsubscribe = eventbase1.subscribe({ id: { $regex: 'unsub:.*' } }, (key, data) => { updates.push({ key, value: data }); }); await eventbase1.put('unsub:1', { value: 1 }); unsubscribe(); await eventbase1.put('unsub:2', { value: 2 }); assert.deepEqual(updates, [{ key: 'unsub:1', value: { id: 'unsub:1', value: 1 } }]); }); test('should update metadata correctly', async () => { const key = 'metadataTest'; await eventbase1.put(key, { value: 1 }); let result = await eventbase1.get(key); assert.equal(result.meta.changes, 1); assert.equal(result.meta.dateCreated, result.meta.dateModified); await new Promise((resolve) => setTimeout(resolve, 10)); await eventbase1.put(key, { value: 2 }); result = await eventbase1.get(key); assert.equal(result.meta.changes, 2); assert.notEqual(result.meta.dateCreated, result.meta.dateModified); }); test('should query the database', async () => { const queryObject = { firstName: { $eq: 'Joe' } }; const expectedResult = [{ id: '1', firstName: 'Joe', lastName: 'Bloggs' }]; await eventbase1.put('1', { firstName: 'Joe', lastName: 'Bloggs' }); const result = await eventbase1.query(queryObject); assert.deepEqual(result, expectedResult); }); test('should delete the stream', async () => { await eventbase1.put('user1', { name: 'Jane Doe' }); await eventbase1.put('user2', { name: 'Jane Doe' }); await eventbase1.put('user3', { name: 'Jane Doe' }); await eventbase1.deleteStream(); const eb = await createEventbase({ dbPath: './test-data/' + streamName + '-node1', nats: { servers: ['localhost:4222'], user: 'a', pass: 'a', }, streamName, statsStreamName, }) const user1 = await eb.get('user1') assert.equal(user1, null) eb.close(); }); });