La mayoría de las bases de datos almacenan el estado actual. Actualizas una fila, el valor anterior desaparece. Eliminas un registro, se desvanece. La base de datos es una instantánea del presente, sin memoria de cómo llegó ahí.
FlinDB almacena el historial completo. Cada guardado, cada actualización, cada eliminación se registra como un evento. El estado actual se deriva del log de eventos, no se almacena independientemente. Este es el modelo EAVT: Entidad-Atributo-Valor-Tiempo. Cada hecho en la base de datos es una tupla de qué entidad cambió, qué atributo cambió, cuál fue el nuevo valor y cuándo sucedió.
La Sesión 168 sentó las bases para el event sourcing EAVT en FlinDB. Es la columna vertebral arquitectónica que hace posibles las consultas temporales, los registros de auditoría y la reproducción de entidades -- y fue inspirado por Datomic, la base de datos Clojure que fue pionera en el almacenamiento inmutable de hechos.
Qué significa EAVT
EAVT significa Entidad-Atributo-Valor-Tiempo. Cada cambio en la base de datos se registra como una tupla:
- Entidad: Qué entidad fue afectada (tipo + ID)
- Atributo: Qué campo cambió
- Valor: Cuál es el nuevo valor (y opcionalmente, cuál era el valor anterior)
- Tiempo: Cuándo ocurrió el cambio
Por ejemplo, actualizar el nombre de un usuario genera este registro EAVT:
Entity: User #42
Attribute: name
Old Value: "Juste"
New Value: "Juste Gnimavo"
Time: 2026-01-13T14:30:00ZEsto es fundamentalmente diferente de una sentencia UPDATE tradicional, que sobrescribe la fila en su lugar. El registro EAVT preserva tanto los valores antiguos como los nuevos, junto con la marca temporal exacta del cambio. El estado anterior nunca se pierde.
La implementación del Event Store
La Sesión 168 introdujo un módulo de event store dedicado (event_store.rs) que captura cada mutación en la base de datos.
El registro EAVT
Cada evento se captura como un EavtRecord:
rustpub struct EavtRecord {
pub event_id: u64,
pub timestamp: i64,
pub version: u64,
pub entity_type: String,
pub entity_id: u64,
pub operation: EventOperation,
pub changes: Vec<FieldChange>,
pub user_id: Option<u64>,
pub reason: Option<String>,
pub correlation_id: Option<String>,
}El event_id es un contador monótonamente creciente que proporciona un ordenamiento total de eventos. El correlation_id permite agrupar eventos relacionados (p. ej., todos los cambios dentro de una sola transacción).
Cambios de campo
Cada registro contiene un vector de cambios a nivel de campo:
rustpub struct FieldChange {
pub field_name: String,
pub old_value: Option<Value>,
pub new_value: Option<Value>,
}Para un evento de creación, old_value es None para todos los campos. Para un evento de actualización, tanto old_value como new_value están presentes para los campos cambiados. Para un evento de eliminación, new_value es None para todos los campos.
Operaciones de eventos
Las cinco operaciones que pueden generar registros EAVT:
rustpub enum EventOperation {
Created,
Updated,
Deleted,
Destroyed,
Restored,
}Nota que FlinDB distingue entre Deleted (eliminación suave -- la entidad se marca como eliminada pero se preserva) y Destroyed (eliminación permanente -- la entidad se elimina permanentemente). Ambas generan registros EAVT, lo que significa que incluso las eliminaciones permanentes son auditables si el log de eventos se preserva separadamente de los datos.
El log de eventos
La estructura EventLog mantiene todos los registros EAVT con múltiples índices para consultas eficientes:
rustpub struct EventLog {
entries: Vec<EavtRecord>,
by_entity: HashMap<(String, u64), Vec<usize>>,
by_timestamp: BTreeMap<i64, Vec<usize>>,
next_event_id: u64,
}Tres estructuras de datos sirven tres patrones de acceso:
entries: Un vector de todos los eventos en orden cronológico. Acceso secuencial para reproducción completa.by_entity: Un HashMap de (entity_type, entity_id) a índices de eventos. Búsqueda O(1) para historial de entidades.by_timestamp: Un BTreeMap de marca temporal a índices de eventos. Consultas de rango para filtrado basado en tiempo.
Agregar
Los nuevos eventos se agregan atómicamente:
rustpub fn append(&mut self, record: EavtRecord) -> u64 {
let event_id = self.next_event_id;
self.next_event_id += 1;
let index = self.entries.len();
// Update entity index
self.by_entity
.entry((record.entity_type.clone(), record.entity_id))
.or_default()
.push(index);
// Update timestamp index
self.by_timestamp
.entry(record.timestamp)
.or_default()
.push(index);
self.entries.push(record);
event_id
}Cada adición actualiza los tres índices en una sola operación. El ID del evento se devuelve al llamador para seguimiento de referencias.
Consulta por entidad
Recuperar el historial completo de una entidad:
rustpub fn events_for_entity(
&self,
entity_type: &str,
id: u64,
) -> Vec<&EavtRecord> {
self.by_entity
.get(&(entity_type.to_string(), id))
.map(|indices| indices.iter().map(|&i| &self.entries[i]).collect())
.unwrap_or_default()
}Esto devuelve todos los eventos para una entidad específica en orden cronológico. Para User #42, obtienes el evento de creación, todos los eventos de actualización y el evento de eliminación (si existe) -- el ciclo de vida completo de esa entidad.
Consulta por rango de tiempo
Recuperar todos los eventos dentro de un rango de tiempo:
rustpub fn events_between(
&self,
from: i64,
to: i64,
) -> Vec<&EavtRecord> {
self.by_timestamp
.range(from..=to)
.flat_map(|(_, indices)| indices.iter().map(|&i| &self.entries[i]))
.collect()
}El BTreeMap proporciona consultas de rango eficientes. "¿Qué pasó entre medianoche y mediodía?" es un solo escaneo de rango, independientemente de cuántos eventos totales existan.
Reproducción de entidades
La capacidad más potente del modelo EAVT es la reproducción de entidades -- reconstruir el estado de una entidad en cualquier punto en el tiempo reproduciendo eventos hasta esa marca temporal:
rustpub fn replay_to(
&self,
entity_type: &str,
id: u64,
at: i64,
) -> Option<EntityInstance> {
let events = self.events_for_entity(entity_type, id);
let mut state: Option<HashMap<String, Value>> = None;
for event in events {
if event.timestamp > at {
break; // Stop replaying at the target timestamp
}
match event.operation {
EventOperation::Created => {
let mut fields = HashMap::new();
for change in &event.changes {
if let Some(ref new_val) = change.new_value {
fields.insert(change.field_name.clone(), new_val.clone());
}
}
state = Some(fields);
}
EventOperation::Updated => {
if let Some(ref mut fields) = state {
for change in &event.changes {
if let Some(ref new_val) = change.new_value {
fields.insert(change.field_name.clone(), new_val.clone());
}
}
}
}
EventOperation::Deleted | EventOperation::Destroyed => {
state = None;
}
EventOperation::Restored => {
// Re-apply the last known state before deletion
}
}
}
state.map(|fields| EntityInstance {
id,
fields,
/* ... */
})
}Así es como funciona el operador de consulta temporal de FLIN (@) bajo la superficie. Cuando un desarrollador escribe:
flinuser @ "2026-01-01"ZeroCore llama a replay_to("User", user_id, timestamp_for("2026-01-01")), que reproduce todos los eventos para ese usuario hasta el 1 de enero de 2026, y devuelve el estado reconstruido.
Integración con ZeroCore
El log de eventos está integrado directamente en las operaciones de mutación de ZeroCore:
rustimpl ZeroCore {
pub fn event_log(&self) -> &EventLog;
pub fn event_log_count(&self) -> usize;
pub fn entity_events(&self, entity_type: &str, id: u64) -> Vec<&EavtRecord>;
pub fn events_between(&self, from: i64, to: i64) -> Vec<&EavtRecord>;
pub fn replay_entity_at(&self, entity_type: &str, id: u64, at: i64) -> Option<EntityInstance>;
pub fn event_log_stats(&self) -> EventLogStats;
}Cada operación save(), delete(), destroy() y restore() en ZeroCore ahora genera un registro EAVT y lo agrega al log de eventos. Los cambios de campo se computan comparando los valores de campos antiguos y nuevos:
rustpub fn compute_field_changes(
old: &HashMap<String, Value>,
new: &HashMap<String, Value>,
) -> Vec<FieldChange> {
let mut changes = Vec::new();
// Fields that changed or were added
for (key, new_val) in new {
let old_val = old.get(key);
if old_val != Some(new_val) {
changes.push(FieldChange {
field_name: key.clone(),
old_value: old_val.cloned(),
new_value: Some(new_val.clone()),
});
}
}
// Fields that were removed
for (key, old_val) in old {
if !new.contains_key(key) {
changes.push(FieldChange {
field_name: key.clone(),
old_value: Some(old_val.clone()),
new_value: None,
});
}
}
changes
}Estadísticas del log de eventos
La estructura EventLogStats proporciona una vista general del log de eventos:
rustpub struct EventLogStats {
pub total_events: usize,
pub events_by_type: HashMap<String, usize>,
pub events_by_operation: HashMap<String, usize>,
pub earliest_timestamp: Option<i64>,
pub latest_timestamp: Option<i64>,
}Esto es útil para monitoreo ("¿cuántos eventos se han acumulado desde la última compactación?"), depuración ("¿qué tipos de operaciones son más comunes?") y planificación de capacidad ("al ritmo actual de eventos, ¿cuándo necesitaremos compactar?").
Por qué EAVT sobre almacenamiento tradicional
El modelo EAVT tiene varias ventajas sobre el almacenamiento tradicional basado en filas:
Registro de auditoría completo. Cada cambio se registra con quién lo hizo, cuándo y cuál era el valor anterior. Los requisitos de cumplimiento (regulaciones financieras, registros de salud, descubrimiento legal) se satisfacen solo con el log de eventos.
Las consultas temporales son gratuitas. Preguntar "¿cuál era el estado de esta entidad el 15 de enero?" es una operación de reproducción en el log de eventos. En una base de datos tradicional, necesitarías construir y mantener una tabla de auditoría separada, un sistema de event sourcing o una extensión de tabla temporal.
La depuración es trivial. Cuando un usuario reporta "mi perfil cambió y yo no lo cambié", puedes consultar el log de eventos para esa entidad y ver exactamente qué cambió, cuándo y (si el seguimiento de usuarios está habilitado) quién hizo el cambio.
Arquitecturas orientadas a eventos. El log de eventos es una fuente natural para sistemas orientados a eventos. Los watchers (implementados vía la sintaxis watch en la Sesión 168) pueden suscribirse a cambios de entidades y reaccionar en tiempo real.
La contrapartida es almacenamiento. Cada cambio genera un evento, y los eventos se acumulan con el tiempo. Para aplicaciones con mucha escritura, el log de eventos puede crecer. La compactación del WAL (también implementada en la Sesión 168) mitiga esto eliminando periódicamente eventos antiguos que han sido guardados en checkpoint en archivos de datos.
La conexión con la sintaxis watch
La Sesión 168 también implementó la sintaxis watch, que permite al código FLIN suscribirse a cambios de entidades:
rust// Bytecode opcodes for watch operations
WatchRegister = 0x90, // Register watcher
WatchUnregister = 0x91, // Unregister watcher
WatchList = 0x92, // List active watchersEl registro de watchers en la VM se conecta al event store:
rustpub struct Watcher {
pub id: u64,
pub entity_type: Option<String>,
pub filter_field: Option<String>,
pub filter_value: Option<Value>,
pub callback: ObjectId,
pub active: bool,
}Cuando se guarda una entidad y se genera un registro EAVT, ZeroCore verifica si algún watcher activo coincide con el evento. Si el tipo de entidad y las condiciones de filtro de un watcher coinciden, se invoca el callback del watcher. Así es como FlinDB proporciona suscripciones en tiempo real sin configuración de WebSocket ni sistemas de mensajería externos.
Las veintidós pruebas
La Sesión 168 agregó veintidós pruebas para el event store EAVT:
Pruebas principales del log de eventos (16): - Adición de eventos con IDs secuenciales - Recuperación de eventos específicos de entidades - Consultas de rango de tiempo - Reproducción de entidades (creación, actualizaciones, eliminación, destrucción, restauración) - Filtrado de eventos por tipo de operación - Recuperación de los últimos N eventos - Estadísticas del log de eventos - Cálculo de cambios de campo (creación, actualización, eliminación) - Formato de visualización de operaciones de eventos
Pruebas de integración ZeroCore (6): - El log de eventos comienza vacío - Acceso al log de eventos - Estadísticas del log de eventos vía ZeroCore - Eventos de entidad para entidad inexistente - Consulta de rango de tiempo en log vacío - Reproducción de entidad inexistente
Después de la Sesión 168: 2.324 pruebas pasando (1.707 de biblioteca + 617 de integración).
El modelo EAVT es la base arquitectónica que hace de FlinDB más que un almacén clave-valor con una API agradable. Es lo que transforma "save user" de una simple operación de datos en un evento temporal, auditable y reproducible -- y es lo que hace posible el operador @ de FLIN.
Esta es la Parte 10 de la serie "Cómo construimos FlinDB", documentando cómo construimos un motor de base de datos embebido completo para el lenguaje de programación FLIN.
Navegación de la serie: - [063] Transactions and Continuous Backup - [064] Graph Queries and Semantic Search - [065] The EAVT Storage Model (estás aquí) - [066] Database Encryption and Configuration - [067] Tree Traversal and Integration Testing