Back to flin
flin

Entity History and Temporal Views in Admin

How the admin console visualizes entity version history and temporal data.

Thales & Claude | March 25, 2026 8 min flin
flinadmintemporalentity-historytime-travel

Most databases treat updates as destructive operations. When you change a user's email address, the old address vanishes. When you update a product's price, the previous price is gone. The record exists only in its current state. Its history is lost.

FLIN's database is temporal by design. Every entity automatically tracks its complete version history. Every save creates a new version. Every version records its timestamp, its field values, and its validity period. Nothing is ever truly overwritten -- the current state is simply the latest version.

Session 266 brought this temporal capability into the admin console and the FLIN language itself through the .history property. This article explains how entity history works at every layer: the database storage, the Rust runtime, the FLIN syntax, and the admin console UI.

The .history Property

The simplest way to access entity history in FLIN is through the .history property on any entity instance:

flin// Get a specific task
task = Todo.find(4)

// Access its complete version history
{for version in task.history}
    <div class="version-row">
        <span class="version-number">v{version.version}</span>
        <span class="title">{version.title}</span>
        <span class="timestamp">{version.updated_at}</span>
    </div>
{/for}

The .history property returns a list of entity instances, one per version. Each historical instance has all the same fields as the current record, plus metadata: version (integer), valid_from (timestamp when this version became active), and valid_to (timestamp when this version was superseded by a newer one).

This is not a query. It is a property access. Writing task.history is as natural as writing task.title. The temporal data is a first-class citizen of the entity model.

How History Is Stored

The version history lives in FLIN's Write-Ahead Log (WAL). Every save operation serializes the complete history array alongside the current record data:

rust// WAL entry for an entity save
#[derive(Serialize, Deserialize)]
pub struct WalEntry {
    pub op_type: String,          // "Save"
    pub entity_type: String,      // "Todo"
    pub entity_id: u64,           // 4
    pub version: u64,             // 6 (current version)
    pub data: serde_json::Value,  // Current field values
    pub history: Vec<EntityVersion>, // All previous versions
}

#[derive(Serialize, Deserialize)]
pub struct EntityVersion {
    pub version: u64,
    pub timestamp: u64,
    pub fields: serde_json::Value,
    pub valid_from: u64,
    pub valid_to: u64,
}

A WAL entry for a Todo item that has been edited five times would contain the current data (version 6) plus five historical versions (versions 1 through 5). Each historical version includes the complete field snapshot at that point in time, not just the diff. This makes reconstruction trivial -- to see what the record looked like at version 3, you read history[2].fields directly.

The storage cost is proportional to the number of edits times the size of the entity. For a typical web application where most records are written once and read many times, the overhead is negligible. For entities that change frequently (counters, status fields), the history can grow large. FLIN's planned compaction strategy will address this by allowing developers to configure retention policies per entity.

The Rust Implementation

The .history property is resolved in the VM's renderer when accessing entity fields. Session 266 added a special case for the history field name:

rust// In src/vm/renderer.rs
"history" => {
    let entity_type = entity.entity_type.clone();
    let entity_id = entity.id;

    // Retrieve history from the database
    let history_versions = vm.database()
        .get_history(&entity_type, entity_id)
        .unwrap_or_default();

    // Convert each EntityVersion into a full EntityInstance
    let mut history_values: Vec<Value> = Vec::new();
    for version in history_versions {
        let hist_entity = EntityInstance {
            entity_type: entity_type.clone(),
            id: entity_id,
            fields: version.fields,
            version: version.version,
            created_at: version.timestamp,
            updated_at: version.timestamp,
            deleted_at: None,
            valid_from: version.valid_from,
            valid_to: version.valid_to,
            resolved_children: Vec::new(),
        };
        let obj_id = vm.alloc(HeapObject::new_entity(hist_entity));
        history_values.push(Value::Object(obj_id));
    }

    // Return as a FLIN list
    let list_id = vm.alloc_list(history_values);
    Value::Object(list_id)
},

Each historical version is wrapped in a full EntityInstance, which means it behaves exactly like a regular entity in templates. You can access version.title, version.created_at, version.valid_from -- all the same field access patterns work. The historical entity is not a special type; it is the same type with different data.

The Admin Console: History View

The entity browser at /_flin/entities provides a history view for individual records. Clicking the history icon on any record row fetches and displays that record's version timeline.

flin// History API endpoint
route GET "/_flin/api/entities/:name/records/:id/history" {
    guard admin_session

    entity_name = params.name
    record_id = int(params.id)

    history = db.get_history(entity_name, record_id)

    versions = history.map(v => {
        version: v.version,
        timestamp: v.timestamp,
        valid_from: format_time(v.valid_from),
        valid_to: format_time(v.valid_to),
        fields: v.fields
    })

    respond json({
        entity: entity_name,
        record_id: record_id,
        current_version: versions.len + 1,
        versions: versions
    })
}

The history view displays a vertical timeline of versions, newest first. Each version shows:

  • Version number and timestamp
  • Changed fields highlighted in a diff view
  • Validity period (valid_from to valid_to)
  • Full field values expandable in a details panel

This gives developers a complete audit trail for any record. When a user reports that their data changed unexpectedly, the developer can navigate to /_flin/entities, find the record, and inspect its history to see exactly when, and to what value, each change occurred.

A Bug Fix That Nearly Broke Everything

Session 266 also fixed a regression that had nothing to do with history but everything to do with scope handling. A previous session's changes had accidentally removed a condition check in the renderer:

rust// BEFORE (broken):
if needs_server_eval(condition) {
    // Evaluate server-side
}

// AFTER (fixed):
if needs_server_eval(condition) || references_scope_vars(condition, scope) {
    // Evaluate server-side
}

The missing references_scope_vars check caused conditions like currentLang == "en" and theme == "light" to be routed to client-side reactive rendering instead of server-side evaluation. The symptoms were bizarre: the language selector showed "EN FR EN FR" (four options instead of two), and the theme toggle stopped working.

The root cause was that scope variables (set with let or var in the template) were not being recognized as server-side values. Without the references_scope_vars check, the renderer assumed these conditions were client-side reactive expressions and wrapped them in <span> elements for dynamic updates -- creating invalid HTML when placed inside <select> elements.

The fix was a single line of Rust. The debugging took considerably longer. This is a recurring pattern in compiler development: the fix is trivial; finding the cause is the hard part.

Temporal Queries in the Query Editor

The Query Editor at /_flin/query supports temporal queries, allowing developers to explore entity history directly:

flin// Get all versions of a specific record
Todo.find(4).history

// Get the version count
Todo.find(4).history.len

// Find records that were modified after a specific date
Todo.where(updated_at > "2026-01-15")

// Combine with regular queries
Todo.where(completed == true).order_by("updated_at").limit(10)

The query editor renders history results as a version timeline table rather than the standard records table, adapting its display format to the shape of the returned data.

Why Temporal Matters for Debugging

Temporal data transforms the admin console from a data viewer into a forensic tool. Consider a common support scenario:

  1. A user reports: "My order status changed from 'shipped' to 'pending' and I did not do anything."
  2. Without temporal data: The developer sees the current status ("pending") and has no way to verify what happened.
  3. With temporal data: The developer opens /_flin/entities, finds the order, clicks the history icon, and sees every version. Version 3 shows "shipped" at 14:23. Version 4 shows "pending" at 14:25. The timestamp and the version data reveal exactly when the change occurred, enabling the developer to correlate it with server logs.

This is the difference between "I cannot reproduce your issue" and "I can see exactly what happened." Temporal data eliminates an entire category of support mysteries.

The Philosophy: Memory as a Feature

FLIN's tagline is "E flin nu" -- a phrase from Fon meaning "It remembers things." The .history property and the admin console's temporal views are the literal implementation of this philosophy. The database remembers every version. The admin console makes those memories visible and queryable.

In a world where most databases forget the moment they update, FLIN's temporal engine is not just a technical feature. It is a trust mechanism. Developers can trust that their data has a complete, verifiable history. Users can trust that their changes are tracked. Auditors can trust that the system maintains a full timeline.

Session 266 made all of this visible from a web browser, at a URL that every FLIN developer already knows: /_flin.

The next article covers the final polish that turned the admin console from a functional tool into a refined product.


This is Part 144 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO brought time-travel database capabilities to a web-based admin console.

Series Navigation: - [143] Storage and Database Admin Views - [144] Entity History and Temporal Views in Admin (you are here) - [145] Console UI/UX Final Polish

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles