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.
// 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:
pub 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:
// 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:
1. 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.
2. 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.
// 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:
pub 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:
// Search with debounce
search_timer = nonefn 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:
// 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}