The todo application is the canonical test of any web framework. It exercises forms, state management, data persistence, and UI rendering in a compact, well-understood package. When we built FLIN's todo app, the UI rendered beautifully. The buttons clicked. The forms submitted. And every single piece of data vanished the moment the page refreshed.
It took three sessions -- 201, 202, 203 -- spread across January 16, 2026, to trace and fix the chain of bugs preventing data from reaching the disk. Each session uncovered a different root cause, and each fix revealed the next problem. It was a debugging relay race where every handoff came with a new obstacle.
Session 201: Bridging Browser and Server
The first session began with a fundamental gap: FLIN's embedded demos did not work in the browser at all. Buttons did nothing. Forms did not submit. The HTML rendered correctly server-side, but no interactivity survived the trip to the browser.
The investigation revealed four distinct problems:
Problem 1: Two-Way Data Binding
The bind={} attribute, which creates two-way data binding on input elements, was not being rendered into functional HTML. When FLIN encountered , it produced:
<input bind="" />Instead of the required:
<input value="" oninput="newTodo = this.value; _flinUpdate()" data-flin-bind="newTodo" />The fix added special handling for the bind attribute in the renderer:
if attr.name == "bind" {
if let AttrValue::Expr(expr) = &attr.value {
let js_expr = expr_to_js(expr);
let value = eval_expr_to_string_with_scope(expr, vm, scope);
return format!(
r#"value="{}" oninput="{} = this.value; _flinUpdate()" data-flin-bind="{}""#,
escape_html_attr(&value),
js_expr,
escape_html_attr(&js_expr)
);
}
}Problem 2: Form Submission
The submit={addTodo} attribute tried to call addTodo() as a JavaScript function. But addTodo only existed as FLIN bytecode on the server. The solution was to generate a POST to the /_action endpoint instead:
<form onsubmit="event.preventDefault(); _flinSubmit('addTodo'); return false;">The _flinSubmit function collects the current state from the $flin proxy, packages it as form data, and POSTs it to the server. The server compiles the FLIN source with the function call appended, executes it in a fresh VM, and returns either a reload signal or an error.
Problem 3: Event Handlers with Arguments
Operations like click={toggleTodo(todo)} and click={removeTodo(todo)} needed to serialize entity references across the browser-server boundary. We created a _flinAction function that serializes entities as {_entity: true, type: "Todo", id: 123} and sends them to the server, where they are reconstructed using Todo.find(123).
Problem 4: The /_action Endpoint
The server needed a new endpoint to handle all these action requests. We implemented handle_action_request in src/server/http.rs, which:
1. Parses the form data to extract the action name and state
2. Determines the source file from the Referer header
3. Reads and compiles the source with the function call appended
4. Creates a VM, injects the browser state, and executes
5. Redirects back to the referring page
After Session 201, buttons clicked, forms submitted, and events fired. The UI was interactive. But the data still did not persist.
Session 202: The Persistence Architecture
Session 202 tackled the core persistence problem: each HTTP request created a VM::new() with an empty, in-memory database. Data saved during one request was lost before the next request began.
The fix was to use VM::with_storage(), which connects the VM to a disk-backed database:
// BEFORE: In-memory only, data lost per request
let mut vm = crate::vm::VM::new();// AFTER: Connected to disk storage let db_path = root_path.join(".flindb"); let mut vm = crate::vm::VM::with_storage(&db_path) .unwrap_or_else(|_| crate::vm::VM::new()); vm.database_mut().set_auto_persist(true); ```
This change was applied to both the page rendering path and the action handler path. Both now used the same .flindb directory in the application root.
After this change, the WAL (Write-Ahead Log) file was created at embedded/todo-app/.flindb/wal.log. Progress. But the file was empty -- zero bytes. The entities were being "saved" to a database that was not actually writing to disk.
Session 203: Three Root Causes for One Empty File
Session 203 was the longest and most revealing. The WAL file existed but contained nothing. This meant the save operation was being called but was failing silently somewhere in the pipeline.
Root Cause 1: Bytecode Overwrites Injected State
When the action handler received a request to call addTodo(), it injected the browser state into the VM:
vm.set_global("newTodo", "Buy groceries");Then it compiled and executed the full FLIN source. But the source file contained:
newTodo = ""This initialization, intended for the first page render, ran during the action execution and overwrote the injected value. By the time addTodo() accessed newTodo, it was an empty string. The task was "created" with an empty title, which either failed validation or produced an invisible entity.
The fix introduced a protected_globals mechanism:
// New VM field
protected_globals: HashSet<String>,// New method pub fn set_global_protected(&mut self, name: String, value: Value) { self.globals.insert(name.clone(), value); self.protected_globals.insert(name); }
// Modified StoreGlobal opcode if !self.protected_globals.contains(&name) { self.globals.insert(name, value); } ```
Protected globals cannot be overwritten by bytecode. The action handler uses set_global_protected instead of set_global, ensuring injected state survives the initialization code.
Root Cause 2: Value::Text Not Handled by Trim
The addTodo function contained a guard condition:
if newTodo.trim() != "" {
// Create the todo
}State injection created Value::Text("Buy groceries"). But the VM's Trim opcode only handled Value::Object (heap-allocated strings). For Value::Text (inline strings), it returned an empty string:
// BEFORE: Only handles Object variant
let s = match &string {
Value::Object(id) => self.get_string(*id)?.trim().to_string(),
_ => String::new(), // Value::Text falls here!
};The trim operation on the injected state returned empty, the guard condition failed, and the todo was never created. The fix was straightforward:
// AFTER: Handles both variants
let s = match &string {
Value::Object(id) => self.get_string(*id)?.trim().to_string(),
Value::Text(t) => t.trim().to_string(), // NEW
_ => String::new(),
};The same fix was needed in extract_string(), which had the same variant oversight.
Root Cause 3: Validators Causing Silent Failures
Even after fixing the state injection and trim handling, the save was failing silently because of entity validators:
entity Todo {
title: text @required @min(1)
done: bool = false
}The @required and @min(1) validators were rejecting the save, but the error was being swallowed by the action handler's error handling. The validator error messages never reached the user or the logs.
As a temporary fix, we removed the validators. The permanent fix -- proper validator error reporting in the action handler -- was deferred to a later session. The immediate priority was proving that the persistence pipeline worked end-to-end.
The Moment of Truth
After all three fixes, we restarted the server and tested:
# Before: WAL file was empty
$ cat embedded/todo-app/.flindb/wal.log
# (empty)# After: WAL file has data! $ cat embedded/todo-app/.flindb/wal.log {"type":"Save","timestamp":1768567212273,"entity_type":"Todo", "entity_id":1,"version":1,"data":{"title":{"Object":453}}, "valid_from":1768567212273,"valid_to":null,"history":[]} ```
Data on disk. For the first time, a FLIN application persisted data through the full pipeline: browser input, form submission, server-side function execution, entity creation, database save, WAL write, and disk flush.
Session 204: Verification
Session 204 was a verification session. We tested the full flow:
1. Added multiple todos through the UI 2. Killed the server 3. Restarted the server 4. Verified all todos were still present
The WAL log contained 20 entries for 9 unique todos with version history. All data survived the restart. The persistence pipeline was complete.
However, we discovered that function calls in templates did not work (filteredTodos() returned None in the renderer), requiring a temporary workaround:
// Before (broken): Function calls not evaluated in templates
{for todo in filteredTodos()}// After (working): Direct entity queries {for todo in Todo.all} ```
This was a renderer limitation, not a persistence bug, and was addressed in later sessions.
The Three-Layer Architecture of Persistence Bugs
Looking back, the three root causes map to three layers of the persistence architecture:
| Layer | Bug | Symptom |
|---|---|---|
| State Injection | Bytecode overwrites injected state | Function receives empty values |
| Type Handling | Value::Text not handled by Trim | Guard condition always fails |
| Validation | Validators reject silently | Save never reaches database |
Each layer independently prevented data from reaching the database. Fixing only one or two would not have been sufficient -- all three had to be resolved for data to flow through the complete pipeline.
This is a pattern we have seen repeatedly in FLIN's development: persistence bugs cluster. When data needs to traverse multiple system boundaries (browser to HTTP, HTTP to VM, VM to database, database to disk), each boundary is an opportunity for data loss. Testing the boundaries individually is necessary but insufficient; only end-to-end testing reveals the interactions between them.
The Broader Lesson
The three-session persistence saga taught us that the gap between "unit tests pass" and "the application works" can be enormous. All 2,870 tests passed throughout this debugging process. The compiler worked. The VM worked. The database worked. The server worked. Each component, tested in isolation, was correct.
But the integration -- the specific sequence of operations that occurs when a user types a todo title, presses Enter, and expects it to still be there tomorrow -- crossed boundaries that no individual test covered. We built five new tests after the fix specifically to exercise this integration path: entity save, state injection with conditions, state injection without conditions, recovery between VMs, and entity queries after recovery.
The persistence fix was not a single elegant solution. It was three separate fixes for three separate bugs, discovered over three sessions spanning an entire day. It was messy, iterative, and unglamorous. It was also absolutely essential. A todo app that does not save todos is not a todo app. It is a typing exercise.
---
This is Part 162 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [161] The Temporal Version Tracking Bug - [162] The Database Persistence Fix That Took 3 Sessions (you are here) - [163] The Layout Children Wrapping Bug