phpMyAdmin changed how developers think about databases. Before it, interacting with MySQL meant typing SQL into a terminal. After it, you could browse tables, edit rows, and run queries from a web browser. It was not sophisticated, but it was transformative.
Session 262 built the FLIN equivalent: a full entity browser with CRUD operations, pagination, search, sort, inline editing, bulk deletion, and export. The difference is that phpMyAdmin is a separate application you install and configure. FLIN's entity browser is embedded in the runtime. It exists the moment your application starts, at /_flin/entities, with zero setup.
The Entity List: From Registry to Records
The entity browser starts with a list of all entities in the application. Unlike Session 259's dashboard, which showed entity counts, the browser needs to show entities that actually have data in the database -- including inline entities that were never formally defined in a .flin file.
rust// In src/server/console/api.rs
pub fn get_entities(db: &ZeroCore) -> Response {
let mut entities = Vec::new();
// Get entities from the database collections (includes inline entities)
for name in db.collection_names() {
let record_count = db.record_count(name);
let schema = db.get_entity_schema(name);
entities.push(json!({
"name": name,
"record_count": record_count,
"fields": schema.map_or(0, |s| s.fields.len()),
"has_schema": schema.is_some(),
}));
}
// Sort alphabetically
entities.sort_by(|a, b| {
a["name"].as_str().unwrap().cmp(b["name"].as_str().unwrap())
});
json_response(200, &json!({ "entities": entities }))
}The critical insight was reading from collection_names() rather than the EntityRegistry. The registry only knows about entities defined in .flin files. But developers frequently create entities inline -- writing save { entity_type: "Note", title: "Hello" } without a formal entity definition. These inline entities exist in the database but not in the registry. The browser shows both.
The Records API: Pagination, Search, and Sort
Once a developer selects an entity, the browser loads its records through a paginated API:
rustpub fn get_records(
db: &ZeroCore,
entity_name: &str,
params: &QueryParams,
) -> Response {
let limit = params.get_int("limit", 25).min(100);
let offset = params.get_int("offset", 0);
let order_by = params.get_str("order_by", "id");
let order = params.get_str("order", "desc");
let search = params.get_str("search", "");
let mut query = db.query(entity_name)
.order_by(order_by, order == "asc");
// Apply search filter across all text fields
if !search.is_empty() {
query = query.search_all_fields(&search);
}
let total = query.count();
let records = query.offset(offset).limit(limit).execute();
json_response(200, &json!({
"records": records,
"total": total,
"limit": limit,
"offset": offset,
"has_more": offset + limit < total,
}))
}The response format is designed for the frontend to build pagination controls without additional API calls. total tells you how many records exist. has_more tells you whether there is a next page. limit and offset echo back the current pagination state so the UI stays synchronized.
Dynamic Column Generation
Unlike phpMyAdmin, which knows the table schema from MySQL's information_schema, FLIN's entity browser must handle entities with and without formal schemas. For schema-less entities (inline entities), the browser inspects the first record's keys to determine columns:
flin// Frontend logic for building the table
fn build_columns(entity_schema, records) {
if entity_schema != none && entity_schema.fields.len > 0 {
// Use schema fields as columns
columns = entity_schema.fields.map(f => f.name)
} else if records.len > 0 {
// Infer columns from first record's keys
columns = records[0].keys().filter(k => k != "_deleted")
} else {
columns = ["id"]
}
// Always include id first, always end with Actions
columns = ["id"] + columns.filter(c => c != "id") + ["Actions"]
columns
}This means the entity browser works for every entity in the system, whether it was defined with a 15-field schema or created on-the-fly with a single save operation.
CRUD Operations
Create
The "New Record" button opens a modal with a form dynamically generated from the entity's schema. Each field gets an appropriate input type:
| FLIN Type | Input Type |
|---|---|
text | Text input |
int / float / number | Number input |
bool | Toggle switch |
time | Datetime picker |
file | File input |
semantic text | Textarea |
For entities without a schema, the modal falls back to a JSON editor where the developer can type raw JSON.
The create operation calls POST /_flin/api/entities/:name/records with the form data serialized as JSON. The API validates the data against the schema (if one exists), generates the next ID, and saves the record to the database.
Read
Reading is the default operation. Loading the entity page fetches the first page of records and displays them in a table. Each cell shows the field value, truncated to a reasonable length for wide text fields.
Update
Two update mechanisms exist:
- Modal edit. Click the pencil icon on any row to open the edit modal, pre-filled with the record's current values. Modify fields and click Save.
- Inline edit. Double-click any cell in the table to convert it into an inline input field. Press Enter to save, Escape to cancel. A green flash confirms the save succeeded.
flin// Inline edit conceptual flow
on double_click(cell) {
original = cell.text
cell.replace_with(input(value: original))
on input.keydown("Enter") {
new_value = input.value
result = put("/_flin/api/entities/{entity}/{id}", {
[field_name]: new_value
})
if result.ok {
cell.flash("green")
cell.text = new_value
}
}
on input.keydown("Escape") {
cell.text = original
}
}Inline editing is the feature that makes the entity browser feel like a spreadsheet rather than a form-based CRUD tool. It reduces the edit workflow from four clicks (pencil icon, modify field, save button, close modal) to two (double-click, Enter).
Delete
Delete operations are soft deletes. The record is not removed from the database; instead, a deleted_at timestamp is set. This aligns with FLIN's temporal database model where nothing is truly deleted -- it is archived.
The delete confirmation modal shows the entity name and record ID, ensuring the developer does not accidentally delete the wrong record. For bulk operations, the developer can select multiple records using checkboxes and delete them in a single API call:
rustpub fn bulk_delete(
db: &mut ZeroCore,
entity_name: &str,
body: &[u8],
) -> Response {
let request: BulkDeleteRequest = serde_json::from_slice(body)?;
let mut deleted = 0;
for id in &request.ids {
if db.soft_delete(entity_name, *id).is_ok() {
deleted += 1;
}
}
json_response(200, &json!({
"success": true,
"deleted": deleted,
}))
}Search: Real-Time With Debounce
The search bar at the top of the records table filters records across all text fields. The implementation uses a 300-millisecond debounce to avoid hammering the API on every keystroke:
flin// Search with debounce
search_timer = none
fn on_search_input(value) {
if search_timer != none {
clear_timeout(search_timer)
}
search_timer = set_timeout(300, fn() {
current_offset = 0 // Reset to first page
fetch_records({ search: value })
})
}The backend search uses a simple contains-match across all string fields of the entity. It is not full-text search -- that is handled by FLIN's semantic search engine in the application layer. The admin console search is intentionally simple: type a name, find the record.
Export: JSON and CSV
The export feature downloads all records (not just the current page) in either JSON or CSV format. Filenames include the entity name and a timestamp: User-2026-01-30T14-23-45.json.
CSV export follows RFC 4180: fields containing commas or quotes are properly escaped. This matters because entity fields frequently contain commas (addresses, descriptions, lists), and a naive CSV export would produce corrupted files.
Column Sorting
Clicking any column header sorts the records by that column. Clicking again toggles between ascending and descending order. A visual arrow indicator shows the current sort direction.
Sorting is server-side, not client-side. The order_by and order query parameters are sent to the API, which passes them to the database query. This ensures correct sorting even when the dataset spans multiple pages -- client-side sorting would only sort the visible page.
Scaling to 90+ Entities
Session 300 revealed a scaling problem. Applications built with FLIN's full entity system -- with auto-generated entities for session management, audit logs, search indexes, and AI embeddings -- could have 90 or more entities. The original two-column layout (entity list on the left, details on the right) became impractical to scroll.
The solution was a horizontal dropdown selector bar at the top of the page:
flin// Entity selector component
<div class="entity-selector-bar">
<button class="selector-trigger" click={toggle_dropdown}>
<span class="entity-name">{current_entity.name}</span>
<span class="entity-count-badge">{entities.len} entities</span>
</button>
{if dropdown_open}
<div class="selector-popover">
<input
type="text"
placeholder="Search entities..."
input={filter_entities}
/>
<div class="entity-list">
{for entity in filtered_entities}
<button
class={entity.name == current_entity.name ? "active" : ""}
click={select_entity(entity)}
>
{entity.name}
<span class="field-count">{entity.fields} fields</span>
<span class="record-count">{entity.record_count} records</span>
</button>
{/for}
</div>
</div>
{/if}
</div>The dropdown includes a search input that filters entities by name as you type. For an application with 90 entities, typing "Us" immediately narrows the list to "User," "UserSession," and "UserPreference." The full-width layout below the selector bar shows field count and record count as summary chips, giving an at-a-glance overview of the selected entity.
This redesign turned the entity browser from a tool that worked for small applications into one that scales to any FLIN project.
The next article covers the feature that protects all of this: admin login and authentication.
This is Part 138 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built a phpMyAdmin-style entity browser into a programming language.
Series Navigation: - [137] The Admin Console Dashboard - [138] Entity Browser and CRUD Operations (you are here) - [139] Admin Login and Authentication