claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
340 lines • 13.6 kB
JavaScript
/**
* RVF Event Log (ADR-057 Phase 2)
*
* Pure-TypeScript append-only event log that stores events in a binary
* file format. Replaces the sql.js-dependent EventStore with a zero-
* dependency alternative.
*
* Binary format:
* File header: 4 bytes — magic "RVFL"
* Record: 4 bytes (uint32 BE payload length) + N bytes (JSON payload)
*
* In-memory indexes are rebuilt on initialize() by replaying the file.
* Snapshots are stored in a separate `.snap.rvf` file using the same format.
*
* @module v3/shared/events/rvf-event-log
*/
import { EventEmitter } from 'node:events';
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync } from 'node:fs';
import { dirname } from 'node:path';
/** Validate a file path is safe */
function validatePath(p) {
if (p.includes('\0'))
throw new Error('Event log path contains null bytes');
}
const DEFAULT_CONFIG = {
logPath: 'events.rvf',
verbose: false,
snapshotThreshold: 100,
};
// =============================================================================
// Constants
// =============================================================================
/** Magic bytes that identify an RVF event log file */
const MAGIC = Buffer.from('RVFL');
const MAGIC_LENGTH = 4;
const LENGTH_PREFIX_BYTES = 4;
// =============================================================================
// RvfEventLog Implementation
// =============================================================================
export class RvfEventLog extends EventEmitter {
config;
initialized = false;
/**
* All events kept in insertion order.
* Rebuilt from the file on initialize().
*/
events = [];
/** Fast lookup: aggregateId -> indices into this.events */
aggregateIndex = new Map();
/** Version tracking per aggregate */
aggregateVersions = new Map();
/** Snapshots keyed by aggregateId (latest wins) */
snapshots = new Map();
/** Path to the companion snapshot file */
snapshotPath;
constructor(config = {}) {
super();
this.config = { ...DEFAULT_CONFIG, ...config };
this.snapshotPath = this.config.logPath.replace(/\.rvf$/, '.snap.rvf');
if (this.snapshotPath === this.config.logPath) {
this.snapshotPath = this.config.logPath + '.snap.rvf';
}
validatePath(this.config.logPath);
}
// ===========================================================================
// Lifecycle
// ===========================================================================
/** Create / open the log file and rebuild in-memory indexes. */
async initialize() {
if (this.initialized)
return;
this.ensureDirectory(this.config.logPath);
// --- events file ---
if (existsSync(this.config.logPath)) {
this.replayFile(this.config.logPath, (event) => {
this.indexEvent(event);
});
}
else {
const tmpLog = this.config.logPath + '.tmp';
writeFileSync(tmpLog, MAGIC);
renameSync(tmpLog, this.config.logPath);
}
// --- snapshots file ---
if (existsSync(this.snapshotPath)) {
this.replayFile(this.snapshotPath, (_raw) => {
const snap = _raw;
this.snapshots.set(snap.aggregateId, snap);
});
}
else {
const tmpSnap = this.snapshotPath + '.tmp';
writeFileSync(tmpSnap, MAGIC);
renameSync(tmpSnap, this.snapshotPath);
}
this.initialized = true;
if (this.config.verbose) {
console.log(`[RvfEventLog] Initialized – ${this.events.length} events, ` +
`${this.snapshots.size} snapshots`);
}
this.emit('initialized');
}
/** Flush to disk and release resources. */
async close() {
if (!this.initialized)
return;
// All data is already on disk (append-only), so just clear memory.
this.events = [];
this.aggregateIndex.clear();
this.aggregateVersions.clear();
this.snapshots.clear();
this.initialized = false;
this.emit('shutdown');
}
// ===========================================================================
// Write Operations
// ===========================================================================
/** Append a domain event to the log. */
async append(event) {
this.ensureInitialized();
if (!event.aggregateId || typeof event.aggregateId !== 'string') {
throw new Error('Event must have a valid aggregateId string');
}
if (!event.type || typeof event.type !== 'string') {
throw new Error('Event must have a valid type string');
}
// Assign next version for aggregate
const currentVersion = this.aggregateVersions.get(event.aggregateId) ?? 0;
const nextVersion = currentVersion + 1;
event.version = nextVersion;
// Persist to disk first (crash-safe ordering)
this.appendRecord(this.config.logPath, event);
// Update in-memory state
this.indexEvent(event);
this.emit('event:appended', event);
if (nextVersion % this.config.snapshotThreshold === 0) {
this.emit('snapshot:recommended', {
aggregateId: event.aggregateId,
version: nextVersion,
});
}
}
/** Save a snapshot for an aggregate. */
async saveSnapshot(snapshot) {
this.ensureInitialized();
this.appendRecord(this.snapshotPath, snapshot);
this.snapshots.set(snapshot.aggregateId, snapshot);
this.emit('snapshot:saved', snapshot);
}
// ===========================================================================
// Read Operations
// ===========================================================================
/** Get events for a specific aggregate, optionally from a version. */
async getEvents(aggregateId, fromVersion) {
this.ensureInitialized();
const indices = this.aggregateIndex.get(aggregateId);
if (!indices || indices.length === 0)
return [];
let result = indices.map((i) => this.events[i]);
if (fromVersion !== undefined) {
result = result.filter((e) => e.version >= fromVersion);
}
// Events within an aggregate are already version-ordered because we
// append in order, but sort defensively.
return result.sort((a, b) => a.version - b.version);
}
/** Query events with an optional filter (matches EventStore.query API). */
async getAllEvents(filter) {
this.ensureInitialized();
if (!filter) {
return [...this.events].sort((a, b) => a.timestamp - b.timestamp);
}
let result = [...this.events];
// Aggregate ID filter
if (filter.aggregateIds && filter.aggregateIds.length > 0) {
const set = new Set(filter.aggregateIds);
result = result.filter((e) => set.has(e.aggregateId));
}
// Aggregate type filter
if (filter.aggregateTypes && filter.aggregateTypes.length > 0) {
const set = new Set(filter.aggregateTypes);
result = result.filter((e) => set.has(e.aggregateType));
}
// Event type filter
if (filter.eventTypes && filter.eventTypes.length > 0) {
const set = new Set(filter.eventTypes);
result = result.filter((e) => set.has(e.type));
}
// Timestamp filters
if (filter.afterTimestamp !== undefined) {
result = result.filter((e) => e.timestamp > filter.afterTimestamp);
}
if (filter.beforeTimestamp !== undefined) {
result = result.filter((e) => e.timestamp < filter.beforeTimestamp);
}
// Version filter
if (filter.fromVersion !== undefined) {
result = result.filter((e) => e.version >= filter.fromVersion);
}
// Sort by timestamp ascending (matches EventStore behaviour)
result.sort((a, b) => a.timestamp - b.timestamp);
// Pagination
if (filter.offset) {
result = result.slice(filter.offset);
}
if (filter.limit) {
result = result.slice(0, filter.limit);
}
return result;
}
/** Get latest snapshot for an aggregate. */
async getSnapshot(aggregateId) {
this.ensureInitialized();
return this.snapshots.get(aggregateId) ?? null;
}
/** Return event store statistics. */
async getStats() {
this.ensureInitialized();
const eventsByType = {};
const eventsByAggregate = {};
let oldest = null;
let newest = null;
for (const event of this.events) {
// by type
eventsByType[event.type] = (eventsByType[event.type] ?? 0) + 1;
// by aggregate
eventsByAggregate[event.aggregateId] =
(eventsByAggregate[event.aggregateId] ?? 0) + 1;
// timestamp range
if (oldest === null || event.timestamp < oldest)
oldest = event.timestamp;
if (newest === null || event.timestamp > newest)
newest = event.timestamp;
}
return {
totalEvents: this.events.length,
eventsByType,
eventsByAggregate,
oldestEvent: oldest,
newestEvent: newest,
snapshotCount: this.snapshots.size,
};
}
/**
* Flush to disk.
* For the append-only log this is a no-op because every append() call
* writes to disk synchronously. Provided for API compatibility with
* EventStore.
*/
async persist() {
// All records are already flushed on append. Nothing to do.
if (this.config.verbose) {
console.log('[RvfEventLog] persist() called — all data already on disk');
}
}
// ===========================================================================
// Private Helpers
// ===========================================================================
/**
* Replay an RVF file and invoke `handler` for every decoded record.
* Used both for events and snapshots.
*/
replayFile(filePath, handler) {
const buf = readFileSync(filePath);
// Validate magic
if (buf.length < MAGIC_LENGTH || buf.subarray(0, MAGIC_LENGTH).compare(MAGIC) !== 0) {
throw new Error(`[RvfEventLog] Invalid file header in ${filePath}`);
}
let offset = MAGIC_LENGTH;
const MAX_PAYLOAD_SIZE = 100 * 1024 * 1024; // 100MB safety limit
while (offset + LENGTH_PREFIX_BYTES <= buf.length) {
const payloadLength = buf.readUInt32BE(offset);
offset += LENGTH_PREFIX_BYTES;
if (payloadLength > MAX_PAYLOAD_SIZE) {
if (this.config.verbose) {
console.warn(`[RvfEventLog] Payload size ${payloadLength} exceeds safety limit`);
}
break;
}
if (offset + payloadLength > buf.length) {
// Truncated record — stop reading (crash recovery).
if (this.config.verbose) {
console.warn(`[RvfEventLog] Truncated record at offset ${offset - LENGTH_PREFIX_BYTES} — ` +
`expected ${payloadLength} bytes, have ${buf.length - offset}`);
}
break;
}
const json = buf.subarray(offset, offset + payloadLength).toString('utf8');
offset += payloadLength;
try {
const record = JSON.parse(json);
handler(record);
}
catch {
if (this.config.verbose) {
console.warn(`[RvfEventLog] Corrupt JSON record skipped`);
}
}
}
}
/** Append a single record to an RVF file. */
appendRecord(filePath, record) {
const json = JSON.stringify(record);
const payload = Buffer.from(json, 'utf8');
const lengthBuf = Buffer.allocUnsafe(LENGTH_PREFIX_BYTES);
lengthBuf.writeUInt32BE(payload.length, 0);
appendFileSync(filePath, Buffer.concat([lengthBuf, payload]));
}
/** Add an event to the in-memory indexes. */
indexEvent(event) {
const idx = this.events.length;
this.events.push(event);
// aggregateIndex
let indices = this.aggregateIndex.get(event.aggregateId);
if (!indices) {
indices = [];
this.aggregateIndex.set(event.aggregateId, indices);
}
indices.push(idx);
// version tracker
const current = this.aggregateVersions.get(event.aggregateId) ?? 0;
if (event.version > current) {
this.aggregateVersions.set(event.aggregateId, event.version);
}
}
/** Ensure parent directory exists for a file path. */
ensureDirectory(filePath) {
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/** Guard that throws if initialize() has not been called. */
ensureInitialized() {
if (!this.initialized) {
throw new Error('RvfEventLog not initialized. Call initialize() first.');
}
}
}
//# sourceMappingURL=rvf-event-log.js.map