Back to flin
flin

Version History and Time Travel Queries

Deep dive into FLIN's @ operator for time-travel queries, the .history property, and how we made accessing past entity states as natural as reading current ones.

Thales & Claude | March 25, 2026 12 min flin
flintime-travelhistoryat-timequeries

The temporal model described in the previous article stores every version of every entity. But storage without access is just expensive disk usage. The real power of FLIN's temporal system lies in how developers query it: the @ operator for point-in-time access, the .history property for full timelines, and a set of temporal keywords that make common queries read like English.

This article covers three interconnected features built across Sessions 012, 075, and 081 -- the @ operator, the .history property, and the infrastructure that makes both feel effortless.

The @ Operator: Time Travel in Two Characters

The @ operator is FLIN's syntax for accessing past entity states. It takes an entity on the left and a temporal reference on the right, and returns the entity as it existed at that point in time.

Three forms of temporal reference are supported.

Relative Version Access

The most common form. A negative integer counts backward from the current version:

user @ -1      // Previous version
user @ -2      // Two versions ago
user @ -10     // Ten versions ago
user @ 0       // First version ever
user @ 1       // Second version

This is what developers reach for most often. "Show me what this looked like before the last change." The syntax is terse by design -- it needs to be comfortable inside expressions, conditionals, and loops.

Absolute Time Access

A date string queries the entity's state at a specific point in time:

user @ "2024-01-15"                    // At specific date
user @ "2024-01-15T14:30:00Z"          // At specific datetime

This form matters for compliance and auditing. "What was the customer's address on the date of the invoice?" is a question that audit systems must answer, and in FLIN it is a single expression.

Keyword Access

Seven built-in keywords cover the most common temporal queries:

user @ yesterday
user @ last_week
user @ last_month
KeywordDescription
nowCurrent moment
todayStart of today (00:00:00 UTC)
yesterdayStart of yesterday
tomorrowStart of tomorrow
last_weekSeven days ago
last_monthThirty days ago
last_yearThree hundred sixty-five days ago

Keywords are not syntactic sugar for date strings. They are first-class tokens in the lexer, parsed as dedicated AST nodes, and compiled to a single-byte time code in the bytecode. user @ yesterday compiles to two instructions: load the user, then AtTime(0x03). No string parsing at runtime. No date arithmetic in application code.

Building the @ Operator: Session 012

The @ operator was implemented in Session 012, a fifty-minute session that established the temporal query infrastructure. The core data structure was an EntityVersion struct stored in a version history map inside the VM:

/// Historical version of an entity for time-travel queries
#[derive(Debug, Clone)]
pub struct EntityVersion {
    pub version: u64,
    pub timestamp: i64,
    pub fields: HashMap<String, Value>,
}

// VM struct additions /// Key: (entity_type, entity_id), Value: historical versions (oldest first) version_history: HashMap<(String, u64), Vec>, ```

The save opcode was modified to snapshot the current state before writing the new version:

1. Clone the entity's current fields into an EntityVersion. 2. Push it onto the version history vector. 3. Increment the version number and update the timestamp. 4. Write the new field values.

This "copy before write" pattern means the history array always contains past versions only. The current version lives in the entity store, and the .history property (discussed below) appends it when constructing the full timeline.

Temporal query execution followed different paths depending on the reference type. Relative access (@ -1) indexed directly into the version history vector. Date-based access (@ "2024-01-15") performed a linear scan for the latest version at or before the target timestamp. Time keyword access (@ yesterday) was initially a stub -- it took six more sessions before it actually worked, a story we will tell in Article 048.

The session also added version history pruning methods, anticipating the storage cost of unbounded history:

/// Prune version history older than timestamp
pub fn prune_versions_before(&mut self, timestamp: i64) -> usize

/// Keep only N most recent versions per entity pub fn prune_versions_keep_last(&mut self, count: usize) -> usize

/// Get total count of stored versions pub fn version_history_count(&self) -> usize ```

Ten new tests. Three hundred and one total tests passing. The temporal query foundation was laid.

Fixing AtTime: When Keywords Did Not Work

There was an embarrassing gap between "the temporal model exists" and "the temporal model works." Session 012 implemented the AtTime opcode as a stub that simply returned the entity unchanged. If you wrote user @ yesterday, you got back the current user. The keyword was parsed, type-checked, compiled, and executed -- but the execution did nothing useful.

This was discovered during the comprehensive audit in Session 068, which found that FLIN's temporal model was simultaneously more complete and more broken than anyone realized. The audit revealed that sixty out of one hundred sixty temporal tasks were already done -- but AtTime was not among them.

The fix required converting time keywords to actual timestamps. Each keyword mapped to a calculation relative to the current system time:

OpCode::AtTime => {
    let time_code = self.read_u8(code);
    let entity_val = self.pop()?;

let target_timestamp = if let Some(tc) = TimeCode::from_byte(time_code) { let now = current_timestamp_ms(); match tc { TimeCode::Now => now, TimeCode::Today => { let secs_today = (now / 1000) - ((now / 1000) % 86400); secs_today * 1000 } TimeCode::Yesterday => { let secs_today = (now / 1000) - ((now / 1000) % 86400); (secs_today - 86400) * 1000 } TimeCode::LastWeek => now - (7 24 60 60 1000), TimeCode::LastMonth => now - (30 24 60 60 1000), TimeCode::LastYear => now - (365 24 60 60 1000), } }; // Find version at target timestamp using history lookup } ```

The same session also fixed the type checker, which previously rejected date strings in @ expressions. The fix was adding FlinType::Text to the allowed types:

match &time_ty {
    FlinType::Int | FlinType::Time | FlinType::Text => {}
    _ => return Err(TypeError::with_hint(
        format!("Expected time reference, found {}", time_ty),
        span,
        "Use a version number like -1, a time keyword like yesterday, \
         or a date string like \"2024-01-15\"",
    )),
}

After Session 068, user @ yesterday actually returned yesterday's version. Seven temporal keywords, all functional.

The .history Property: Session 075

The @ operator gives you a single version. The .history property gives you all of them. It was the most requested temporal feature during development, and it turned out to be ninety percent implemented before anyone realized it.

Session 075 set out to implement .history from scratch. What we found was that every layer of the stack already had support:

  • The parser handled .history as member access (already existed).
  • The type checker returned FlinType::List for .history (already implemented).
  • The code generator emitted OpCode::History (already wired up).
  • The VM had a History opcode handler (already implemented).
  • The database had a get_history() method (already working).

The only problems were bugs. Two bugs, specifically, that made the feature silently produce wrong results.

Bug 1: History Duplication

ZeroCore was adding the initial version to the history array when an entity was first saved. Then the VM's OpCode::History handler was adding the current version again when constructing the result. The output was [v1, v1] for a single save, and [v1, v2, v2] for two saves.

The fix established a critical semantic rule: the history array contains past versions only. The current version is appended by the VM when returning results. This prevents duplication and keeps the storage layer's responsibility clean.

// BEFORE (ZeroCore): Added initial version to history -- WRONG
collection.entities.insert(new_id, entity.clone());
collection.history.entry(new_id).or_default().push(history_entry);

// AFTER: History contains only PAST versions collection.entities.insert(new_id, entity.clone()); // NOTE: Do NOT add initial version to history. // The .history property (in VM) adds the current version at the end. ```

Bug 2: Unsaved Entity History

Unsaved entities (with id == 0) were returning [current] instead of []. An entity that has never been saved has no history -- it does not exist in the database yet. The fix added an is_saved check:

let is_saved = entity_id != 0;
let history_data = if is_saved {
    self.database.get_history(&type_name, entity_id).unwrap_or_default()
} else {
    Vec::new()
};

// Add current version only if entity has been saved if is_saved { versions.push(entity_val); } ```

With these two fixes, .history worked correctly. Five new tests passed, bringing temporal integration coverage from eleven out of twenty-seven to sixteen out of twenty-seven.

Using .history in Practice

The .history property returns a list of entity instances, ordered oldest to newest. Each instance has all its fields populated with the values from that version, plus metadata (version number, timestamps).

Full history iteration:

{for version in user.history}
    <p>{version.name} at {version.created_at}</p>
{/for}

Last N versions:

last_5 = user.history.last(5)

Version count:

total_versions = user.history.count

Filtered history (implemented in Session 082, covered in Article 050):

expensive = product.history.where_field("price", ">", 100)

The combination of @ for point-in-time access and .history for full timelines covers the two fundamental patterns of temporal queries: "What was it then?" and "How did it change over time?"

The @ Operator Returns Optional

One design decision worth highlighting: temporal access returns an optional value. The requested version might not exist -- the entity might not have existed at that time, or the relative offset might exceed the number of versions.

old_user = user @ "2020-01-01"        // User? (might not exist)

{if old_user}

User existed: {old_user.name}

{else}

User did not exist yet

{/if} ```

This forces developers to handle the absence case, preventing null pointer exceptions when querying historical data. The type system enforces it -- you cannot access .name on the result of @ without first checking that it is not none.

In the VM, out-of-range relative access simply returns None without an error. user @ -100 when there are only three versions produces None, not a crash. This was validated in Session 076 when we discovered that the VM already handled missing versions gracefully -- no explicit bounds checking was needed.

Comparing Across Time

The @ operator composes naturally with comparison expressions:

// Did the name change since the last version?
name_changed = user.name != (user @ -1).name

// Did the price increase since yesterday? price_increased = product.price > (product @ yesterday).price

// Compare user from different time periods jan_user = user @ "2024-01-01" dec_user = user @ "2024-12-01"

{if jan_user && dec_user} {if jan_user.name != dec_user.name}

Name changed from {jan_user.name} to {dec_user.name}

{/if} {/if} ```

These patterns look obvious in retrospect, but they are only possible because @ returns a full entity with all fields populated. If @ returned only the changed fields (like a diff), these comparisons would require assembly code-level reconstruction. By storing complete snapshots, we trade storage for simplicity -- and simplicity wins every time for the applications FLIN targets.

What Made This Hard

Three aspects of temporal queries required more engineering effort than expected.

First, the borrow checker. Rust's ownership model made temporal queries unusually difficult to implement. The VM needs to read from the version history (immutable borrow) while simultaneously modifying the stack (mutable borrow). The solution was to clone entity data before mutable operations:

let entity_data = {
    let obj = self.get_object(obj_id)?;
    obj.as_entity().cloned()
};

This pattern appeared throughout the temporal implementation. It is slightly less efficient than zero-copy access, but it satisfies the borrow checker and prevents data races that could corrupt version history.

Second, the layered architecture. Temporal queries touch every compiler layer -- lexer, parser, type checker, code generator, VM, and database. A bug in any layer produces confusing errors in another. The AtTime stub returned the current entity without error, making it look like time keywords worked when they did not. Only end-to-end integration tests caught this.

Third, reactive rendering. FLIN generates HTML with data-flin-bind attributes for client-side reactivity. Temporal values inside templates were wrapped in reactive spans, which broke test assertions that expected plain text output. The fix was to update test expectations to account for the reactive wrapper: checking for >1< instead of 1.

The Full Picture

By the end of Session 081, FLIN's temporal access system was complete:

  • Three forms of @ access (relative, absolute, keyword).
  • Seven temporal keywords.
  • The .history property for full timelines.
  • Optional return types preventing null pointer errors.
  • Version metadata on every historical snapshot.
  • End-to-end test coverage from lexer to rendered HTML.

The temporal access system is the developer-facing API of FLIN's memory-native data model. Behind it, the bitemporal storage engine indexes versions for O(log n) point-in-time queries and O(1) current version access. In front of it, developers write user @ yesterday and get exactly what they expect.

No audit table. No changelog middleware. No event sourcing framework. Two characters and a keyword.

---

This is Part 2 of the "How We Built FLIN" temporal model series, documenting the design and implementation of FLIN's time-travel query system.

Series Navigation: - [046] Every Entity Remembers Everything: The Temporal Model - [047] Version History and Time Travel Queries (you are here) - [048] Temporal Integration: From Bugs to 100% Test Coverage - [049] Destroy and Restore: Soft Deletes Done Right - [050] Temporal Filtering and Ordering - [051] Temporal Comparison Helpers - [052] Version Metadata Access - [053] Time Arithmetic: Adding Days, Comparing Dates - [054] Tracking Accuracy and Validation - [055] The Temporal Model Complete: What No Other Language Has

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles