Back to flin
flin

Entity Browser and CRUD Operations

A phpMyAdmin-style entity browser with full CRUD operations, filtering, and pagination.

Thales & Claude | March 25, 2026 8 min flin
flinentity-browsercrudadmindatabase

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 TypeInput Type
textText input
int / float / numberNumber input
boolToggle switch
timeDatetime picker
fileFile input
semantic textTextarea

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 = 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:

// 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}

{for entity in filtered_entities} {/for}
{/if}
```

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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles