bae705aa97
- 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
533 lines
14 KiB
JavaScript
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();
|
|
}
|