/** * QUIXZOOM Event Store * * Event sourcing: Allt börjar som ett event. * * Event-flöde: * ObservationCreated * ↓ * ObservationValidated * ↓ * EvidenceAdded * ↓ * ObjectUpdated * ↓ * MissionGenerated * ↓ * LearningQueueUpdated * * Fördelar: * - Hela stadens historik kan spelas upp igen * - Algoritmer kan förbättras retroaktivt * - Full audit trail * - Inga data förloras */ class EventStore { constructor(options = {}) { this.events = []; // Alla events i ordning this.streams = new Map(); // streamId -> [events] this.projections = new Map(); // projectionName -> state this.handlers = new Map(); // eventType -> [handlers] // Event-typer this.eventTypes = new Set([ 'ObservationCreated', 'ObservationValidated', 'ObservationRejected', 'EvidenceAdded', 'EvidenceVerified', 'ObjectRegistered', 'ObjectUpdated', 'ObjectMerged', 'MissionCreated', 'MissionAssigned', 'MissionCompleted', 'MissionFailed', 'LearningQueueUpdated', 'KnowledgeGapIdentified', 'KnowledgeGapClosed', ]); } /** * ============================================================ // LÄGGA TILL EVENT * ============================================================ */ append(event) { // Validera event if (!this.validateEvent(event)) { throw new Error(`Invalid event: ${JSON.stringify(event)}`); } // Lägg till metadata const enrichedEvent = { ...event, sequenceNumber: this.events.length, timestamp: event.timestamp || Date.now(), }; // Spara i global logg this.events.push(enrichedEvent); // Spara i stream const streamId = event.streamId || event.aggregateId || 'global'; if (!this.streams.has(streamId)) { this.streams.set(streamId, []); } this.streams.get(streamId).push(enrichedEvent); // Trigga handlers this.triggerHandlers(enrichedEvent); console.log(`[EVENT] ${event.type} #${enrichedEvent.sequenceNumber} in stream ${streamId}`); return enrichedEvent; } validateEvent(event) { if (!event.type) { console.error('Event missing type'); return false; } if (!this.eventTypes.has(event.type)) { console.warn(`Unknown event type: ${event.type}`); // Tillåt ändå — kan vara custom event } if (!event.aggregateId && !event.streamId) { console.error('Event missing aggregateId/streamId'); return false; } return true; } /** * ============================================================ // HÄMTA EVENTS * ============================================================ */ getAllEvents() { return [...this.events]; } getEventsByStream(streamId) { return this.streams.get(streamId) || []; } getEventsByType(type) { return this.events.filter(e => e.type === type); } getEventsSince(sequenceNumber) { return this.events.filter(e => e.sequenceNumber >= sequenceNumber); } getEventsInRange(startTime, endTime) { return this.events.filter(e => e.timestamp >= startTime && e.timestamp <= endTime); } /** * ============================================================ // PROJECTIONS (läs-modeller) * ============================================================ */ createProjection(name, projector) { this.projections.set(name, { state: projector.initialState || {}, projector, lastSequenceNumber: -1, }); // Spela upp alla befintliga events this.rebuildProjection(name); console.log(`[PROJECTION] Created: ${name}`); } rebuildProjection(name) { const projection = this.projections.get(name); if (!projection) return; // Återställ state projection.state = projection.projector.initialState || {}; projection.lastSequenceNumber = -1; // Spela upp alla events for (const event of this.events) { this.applyToProjection(name, event); } console.log(`[PROJECTION] Rebuilt: ${name} (${this.events.length} events)`); } applyToProjection(name, event) { const projection = this.projections.get(name); if (!projection) return; const handler = projection.projector[event.type]; if (handler) { projection.state = handler(projection.state, event); projection.lastSequenceNumber = event.sequenceNumber; } } getProjection(name) { const projection = this.projections.get(name); return projection ? projection.state : null; } /** * ============================================================ // EVENT HANDLERS * ============================================================ */ on(eventType, handler) { if (!this.handlers.has(eventType)) { this.handlers.set(eventType, []); } this.handlers.get(eventType).push(handler); } triggerHandlers(event) { const handlers = this.handlers.get(event.type) || []; for (const handler of handlers) { try { handler(event); } catch (error) { console.error(`Handler error for ${event.type}:`, error); } } } /** * ============================================================ // REPLAY (spela upp historik) * ============================================================ */ replay(options = {}) { const { fromSequence = 0, toSequence = this.events.length, filter } = options; const eventsToReplay = this.events .filter(e => e.sequenceNumber >= fromSequence && e.sequenceNumber < toSequence) .filter(e => !filter || filter(e)); console.log(`[REPLAY] Replaying ${eventsToReplay.length} events (${fromSequence}-${toSequence})`); const results = []; for (const event of eventsToReplay) { // Här kan man göra vad man vill med events results.push(event); } return results; } /** * ============================================================ // STANDARD PROJECTIONS * ============================================================ */ setupStandardProjections() { // 1. Object-projection this.createProjection('objects', { initialState: {}, ObjectRegistered: (state, event) => { state[event.aggregateId] = { id: event.aggregateId, type: event.payload.type, location: event.payload.location, attributes: event.payload.attributes, observations: [], evidence: [], createdAt: event.timestamp, updatedAt: event.timestamp, }; return state; }, ObjectUpdated: (state, event) => { const obj = state[event.aggregateId]; if (obj) { obj.attributes = { ...obj.attributes, ...event.payload.attributes }; obj.updatedAt = event.timestamp; } return state; }, ObjectMerged: (state, event) => { const target = state[event.payload.targetId]; const source = state[event.payload.sourceId]; if (target && source) { target.observations.push(...source.observations); target.evidence.push(...source.evidence); delete state[event.payload.sourceId]; } return state; }, ObservationCreated: (state, event) => { const obj = state[event.payload.objectId]; if (obj) { obj.observations.push(event.payload.observationId); obj.updatedAt = event.timestamp; } return state; }, EvidenceAdded: (state, event) => { const obj = state[event.payload.objectId]; if (obj) { obj.evidence.push(event.payload.evidenceId); obj.updatedAt = event.timestamp; } return state; }, }); // 2. Mission-projection this.createProjection('missions', { initialState: {}, MissionCreated: (state, event) => { state[event.aggregateId] = { id: event.aggregateId, type: event.payload.type, status: 'pending', areaId: event.payload.areaId, targetLocation: event.payload.targetLocation, createdAt: event.timestamp, }; return state; }, MissionAssigned: (state, event) => { const mission = state[event.aggregateId]; if (mission) { mission.status = 'assigned'; mission.assignedTo = event.payload.zoomerId; mission.assignedAt = event.timestamp; } return state; }, MissionCompleted: (state, event) => { const mission = state[event.aggregateId]; if (mission) { mission.status = 'completed'; mission.completedAt = event.timestamp; mission.result = event.payload.result; } return state; }, MissionFailed: (state, event) => { const mission = state[event.aggregateId]; if (mission) { mission.status = 'failed'; mission.failedAt = event.timestamp; mission.reason = event.payload.reason; } return state; }, }); // 3. Statistics-projection this.createProjection('statistics', { initialState: { totalObservations: 0, totalObjects: 0, totalMissions: 0, completedMissions: 0, byType: {}, }, ObjectRegistered: (state, event) => { state.totalObjects++; const type = event.payload.type; state.byType[type] = (state.byType[type] || 0) + 1; return state; }, ObservationCreated: (state, event) => { state.totalObservations++; return state; }, MissionCreated: (state, event) => { state.totalMissions++; return state; }, MissionCompleted: (state, event) => { state.completedMissions++; return state; }, }); console.log('[EVENT] Standard projections setup complete'); } /** * ============================================================ // DEMONSTRATION * ============================================================ */ demonstrate() { console.log('=== EVENT STORE DEMO ===\n'); // Setup projections this.setupStandardProjections(); // 1. Skapa observation console.log('1. Creating observation...'); this.append({ type: 'ObservationCreated', aggregateId: 'obs_001', streamId: 'observations', payload: { observationId: 'obs_001', objectId: 'obj_001', sourceType: 'quixzoom', sourceId: 'zoomer_001', location: { lat: 13.725, lng: 100.555 }, timestamp: Date.now(), }, }); // 2. Registrera objekt console.log('2. Registering object...'); this.append({ type: 'ObjectRegistered', aggregateId: 'obj_001', streamId: 'objects', payload: { type: 'street_lamp', location: { lat: 13.725, lng: 100.555 }, attributes: { material: 'steel', height: 7.8 }, }, }); // 3. Validera observation console.log('3. Validating observation...'); this.append({ type: 'ObservationValidated', aggregateId: 'obs_001', streamId: 'observations', payload: { observationId: 'obs_001', validatedBy: 'ai_system', confidence: 0.94, }, }); // 4. Lägg till evidens console.log('4. Adding evidence...'); this.append({ type: 'EvidenceAdded', aggregateId: 'obj_001', streamId: 'objects', payload: { evidenceId: 'ev_001', objectId: 'obj_001', sourceType: 'quixzoom', reliability: 0.85, }, }); // 5. Uppdatera objekt console.log('5. Updating object...'); this.append({ type: 'ObjectUpdated', aggregateId: 'obj_001', streamId: 'objects', payload: { attributes: { rust: true, lean: 2 }, }, }); // 6. Skapa uppdrag console.log('6. Creating mission...'); this.append({ type: 'MissionCreated', aggregateId: 'mission_001', streamId: 'missions', payload: { type: 'verify', areaId: 'bangkok_sukhumvit', targetLocation: { lat: 13.725, lng: 100.555 }, }, }); // 7. Tilldela uppdrag console.log('7. Assigning mission...'); this.append({ type: 'MissionAssigned', aggregateId: 'mission_001', streamId: 'missions', payload: { zoomerId: 'zoomer_002', }, }); // 8. Slutför uppdrag console.log('8. Completing mission...'); this.append({ type: 'MissionCompleted', aggregateId: 'mission_001', streamId: 'missions', payload: { result: 'verified', observationId: 'obs_002', }, }); console.log('\n=== PROJECTIONS ===\n'); // Visa object-projection const objects = this.getProjection('objects'); console.log('Objects:', JSON.stringify(objects, null, 2)); // Visa mission-projection const missions = this.getProjection('missions'); console.log('\nMissions:', JSON.stringify(missions, null, 2)); // Visa statistics-projection const stats = this.getProjection('statistics'); console.log('\nStatistics:', JSON.stringify(stats, null, 2)); console.log('\n=== EVENT LOG ==='); console.log(`Total events: ${this.events.length}`); for (const event of this.events) { console.log(` #${event.sequenceNumber}: ${event.type} (${event.timestamp})`); } console.log('\n=== REPLAY ==='); const replayed = this.replay({ fromSequence: 2, toSequence: 5 }); console.log(`Replayed ${replayed.length} events:`); for (const event of replayed) { console.log(` #${event.sequenceNumber}: ${event.type}`); } } } // Exportera module.exports = EventStore; // Demo if (require.main === module) { const store = new EventStore(); store.demonstrate(); }