Back to flin
flin

The EAVT Storage Model

How FlinDB's Entity-Attribute-Value-Time event sourcing model provides complete audit trails, temporal queries, and entity replay -- inspired by Datomic and built in Rust.

Thales & Claude | March 25, 2026 9 min flin
flinflindbeavtstoragearchitecture

Most databases store the current state. You update a row, the old value is gone. You delete a record, it vanishes. The database is a snapshot of the present, with no memory of how it got there.

FlinDB stores the entire history. Every save, every update, every deletion is recorded as an event. The current state is derived from the event log, not stored independently. This is the EAVT model: Entity-Attribute-Value-Time. Every fact in the database is a tuple of which entity changed, what attribute changed, what the new value was, and when it happened.

Session 168 laid the foundation for EAVT event sourcing in FlinDB. It is the architectural backbone that makes temporal queries, audit trails, and entity replay possible -- and it was inspired by Datomic, the Clojure database that pioneered immutable fact storage.

What EAVT Means

EAVT stands for Entity-Attribute-Value-Time. Every change to the database is recorded as a tuple:

  • Entity: Which entity was affected (type + ID)
  • Attribute: Which field changed
  • Value: What the new value is (and optionally, what the old value was)
  • Time: When the change occurred

For example, updating a user's name generates this EAVT record:

Entity: User #42
Attribute: name
Old Value: "Juste"
New Value: "Juste Gnimavo"
Time: 2026-01-13T14:30:00Z

This is fundamentally different from a traditional UPDATE statement, which overwrites the row in place. The EAVT record preserves both the old and new values, along with the exact timestamp of the change. The old state is never lost.

The Event Store Implementation

Session 168 introduced a dedicated event store module (event_store.rs) that captures every mutation in the database.

The EAVT Record

Each event is captured as an EavtRecord:

pub 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>,
}

The event_id is a monotonically increasing counter that provides a total ordering of events. The correlation_id allows grouping related events (e.g., all changes within a single transaction).

Field Changes

Each record contains a vector of field-level changes:

pub struct FieldChange {
    pub field_name: String,
    pub old_value: Option<Value>,
    pub new_value: Option<Value>,
}

For a creation event, old_value is None for all fields. For an update event, both old_value and new_value are present for changed fields. For a deletion event, new_value is None for all fields.

Event Operations

The five operations that can generate EAVT records:

pub enum EventOperation {
    Created,
    Updated,
    Deleted,
    Destroyed,
    Restored,
}

Note that FlinDB distinguishes between Deleted (soft delete -- entity is marked as deleted but preserved) and Destroyed (hard delete -- entity is permanently removed). Both generate EAVT records, which means even hard deletions are auditable if the event log is preserved separately from the data.

The Event Log

The EventLog struct maintains all EAVT records with multiple indexes for efficient querying:

pub struct EventLog {
    entries: Vec<EavtRecord>,
    by_entity: HashMap<(String, u64), Vec<usize>>,
    by_timestamp: BTreeMap<i64, Vec<usize>>,
    next_event_id: u64,
}

Three data structures serve three access patterns:

1. entries: A vector of all events in chronological order. Sequential access for full replay. 2. by_entity: A HashMap from (entity_type, entity_id) to event indices. O(1) lookup for entity history. 3. by_timestamp: A BTreeMap from timestamp to event indices. Range queries for time-based filtering.

Append

New events are appended atomically:

pub 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 } ```

Each append updates all three indexes in a single operation. The event ID is returned to the caller for reference tracking.

Query by Entity

Retrieving the complete history of an entity:

pub 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()
}

This returns all events for a specific entity in chronological order. For User #42, you get the creation event, all update events, and the deletion event (if any) -- the complete lifecycle of that entity.

Query by Time Range

Retrieving all events within a time range:

pub 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()
}

The BTreeMap provides efficient range queries. "What happened between midnight and noon?" is a single range scan, regardless of how many total events exist.

Entity Replay

The most powerful capability of the EAVT model is entity replay -- reconstructing an entity's state at any point in time by replaying events up to that timestamp:

pub 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> = 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, / ... / }) } ```

This is how FlinDB's temporal query operator (@) works under the hood. When a developer writes:

user @ "2026-01-01"

ZeroCore calls replay_to("User", user_id, timestamp_for("2026-01-01")), which replays all events for that user up to January 1st, 2026, and returns the reconstructed state.

ZeroCore Integration

The event log is integrated directly into ZeroCore's mutation operations:

impl 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;
}

Every save(), delete(), destroy(), and restore() operation in ZeroCore now generates an EAVT record and appends it to the event log. The field changes are computed by comparing the old and new field values:

pub 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 } ```

Event Log Statistics

The EventLogStats struct provides a high-level overview of the event log:

pub 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>,
}

This is useful for monitoring ("how many events have accumulated since the last compaction?"), debugging ("what types of operations are most common?"), and capacity planning ("at the current event rate, when will we need to compact?").

Why EAVT Over Traditional Storage

The EAVT model has several advantages over traditional row-based storage:

Complete audit trail. Every change is recorded with who made it, when, and what the previous value was. Compliance requirements (financial regulations, healthcare records, legal discovery) are satisfied by the event log alone.

Temporal queries are free. Asking "what was this entity's state on January 15th?" is a replay operation on the event log. In a traditional database, you would need to build and maintain a separate audit table, event sourcing system, or temporal table extension.

Debugging is trivial. When a user reports "my profile changed and I did not change it," you can query the event log for that entity and see exactly what changed, when, and (if user tracking is enabled) who made the change.

Event-driven architectures. The event log is a natural source for event-driven systems. Watchers (implemented via the watch syntax in Session 168) can subscribe to entity changes and react in real-time.

The trade-off is storage. Every change generates an event, and events accumulate over time. For write-heavy applications, the event log can grow large. WAL compaction (also implemented in Session 168) mitigates this by periodically removing old events that have been checkpointed to data files.

The Watch Syntax Connection

Session 168 also implemented the watch syntax, which allows FLIN code to subscribe to entity changes:

// Bytecode opcodes for watch operations
WatchRegister = 0x90,   // Register watcher
WatchUnregister = 0x91, // Unregister watcher
WatchList = 0x92,       // List active watchers

The watcher registry in the VM connects to the event store:

pub 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,
}

When an entity is saved and an EAVT record is generated, ZeroCore checks if any active watchers match the event. If a watcher's entity type and filter conditions match, the watcher's callback is invoked. This is how FlinDB provides real-time subscriptions without WebSocket configuration or external messaging systems.

The Twenty-Two Tests

Session 168 added twenty-two tests for the EAVT event store:

Event log core tests (16): - Event append with sequential IDs - Entity-specific event retrieval - Time range queries - Entity replay (creation, updates, deletion, destruction, restoration) - Event filtering by operation type - Last N events retrieval - Event log statistics - Field change computation (creation, update, removal) - Event operation display formatting

ZeroCore integration tests (6): - Event log starts empty - Event log accessor - Event log statistics via ZeroCore - Entity events for non-existent entity - Time range query on empty log - Replay of non-existent entity

After Session 168: 2,324 tests passing (1,707 library + 617 integration).

The EAVT model is the architectural foundation that makes FlinDB more than a key-value store with a nice API. It is what transforms "save user" from a simple data operation into a temporal, auditable, replayable event -- and it is what makes FLIN's @ operator possible.

---

This is Part 10 of the "How We Built FlinDB" series, documenting how we built a complete embedded database engine for the FLIN programming language.

Series Navigation: - [063] Transactions and Continuous Backup - [064] Graph Queries and Semantic Search - [065] The EAVT Storage Model (you are here) - [066] Database Encryption and Configuration - [067] Tree Traversal and Integration Testing

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles