Files
boc/quixzoom-capture-pipeline/persistence/event-store.js
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

533 lines
14 KiB
JavaScript

/**
* 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();
}