Session 262 built the entity browser's foundation: a records table with create, read, update, and delete operations. It worked. You could browse entities, add records, edit them, and remove them. But it worked the way a prototype works -- it handled the happy path and not much else.
Session 263 transformed the prototype into a tool. Twelve enhancements in a single session: column sorting, real-time search, record export, bulk selection and deletion, inline editing, compound WHERE conditions, aggregation queries, query result export, and live dashboard statistics. Each feature was small individually. Together, they turned the entity browser from something you used because you had to into something you used because it was the fastest way to interact with your data.
Column Sorting: Click to Order
The records table gained clickable column headers. Click a column to sort ascending. Click again to sort descending. A visual arrow indicator shows the current sort direction and column.
// Sorting state management
current_sort_column = "id"
current_sort_direction = "desc"fn sort_by(column) { if current_sort_column == column { // Toggle direction current_sort_direction = if current_sort_direction == "asc" { "desc" } else { "asc" } } else { // New column, default to descending current_sort_column = column current_sort_direction = "desc" }
fetch_records() } ```
Sorting is server-side. The order_by and order query parameters are passed to the API, which delegates to the database. This ensures correct ordering across paginated results -- a detail that client-side sorting gets wrong when only a subset of records is loaded.
Search: Debounced, Real-Time
A search input above the records table filters records across all text fields. The implementation uses a 300-millisecond debounce timer that resets on every keystroke:
search_timeout = nonefn on_search(value) { if search_timeout != none { clear_timeout(search_timeout) }
search_timeout = set_timeout(300, fn() { current_offset = 0 // Reset pagination fetch_records({ search: value }) }) } ```
The debounce prevents a flood of API calls as the developer types. Without it, searching for "John" would trigger four requests: "J", "Jo", "Joh", "John." With the debounce, only the final "John" fires if the developer types quickly enough.
The backend search is a case-insensitive substring match across all string fields. This is intentionally simple -- it is a data browser, not a search engine. For full-text search, developers use FLIN's semantic search features in their application code.
Export: JSON and CSV
Two export buttons appear above the records table. Click "JSON" to download all records as a pretty-printed JSON array. Click "CSV" to download an RFC 4180-compliant CSV file.
The filenames include the entity name and a timestamp to prevent overwrites:
fn export_records(format) {
records = fetch_all_records() // No pagination limitif format == "json" { content = json_stringify(records, indent: 2) filename = "{entity_name}-{timestamp}.json" } else { // RFC 4180: escape commas and quotes header = columns.join(",") rows = records.map(r => { columns.map(c => { value = str(r[c]) if value.contains(",") || value.contains("\"") { "\"" + value.replace("\"", "\"\"") + "\"" } else { value } }).join(",") }) content = header + "\n" + rows.join("\n") filename = "{entity_name}-{timestamp}.csv" }
download(content, filename) } ```
The CSV escaping is not glamorous, but it is essential. Entity fields frequently contain commas (addresses like "Abidjan, Cocody"), and a naive CSV export that does not escape them produces files that Excel interprets incorrectly.
Bulk Select and Delete
Checkboxes appear on each row. A master checkbox in the header toggles all visible records. When one or more records are selected, a bulk action bar appears at the top of the table:
// Bulk delete API endpoint
pub fn bulk_delete_records(
db: &mut ZeroCore,
entity_name: &str,
body: &[u8],
) -> Response {
let request: BulkDeleteRequest = serde_json::from_slice(body)?;if request.ids.is_empty() { return json_response(400, &json!({ "error": "No record IDs provided" })); }
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, "requested": request.ids.len(), })) } ```
The bulk delete uses a single API call (POST /_flin/api/entities/:name/records/bulk-delete) rather than individual DELETE requests per record. This reduces network round-trips and ensures atomicity -- either all selected records are soft-deleted, or the operation fails.
Inline Editing: Double-Click to Modify
Double-clicking any cell in the records table converts it into an inline input field. The cell's current value is pre-filled. Press Enter to save, Escape to cancel.
fn on_cell_double_click(cell, record_id, field_name) {
original_value = cell.text_content
input = create_input(value: original_value)
cell.replace_children(input)
input.focus()
input.select_all()on input.keydown("Enter") { new_value = parse_value(input.value, field_type) result = put("/_flin/api/entities/{entity}/{record_id}", { [field_name]: new_value })
if result.ok { cell.text_content = str(new_value) cell.add_class("flash-green") after(500, fn() { cell.remove_class("flash-green") }) } else { cell.text_content = original_value show_error(result.error) } }
on input.keydown("Escape") { cell.text_content = original_value }
on input.blur { // Save on blur (same as Enter) // ... same logic as Enter handler } } ```
The green flash on successful save is a small but significant UX detail. It provides instant visual confirmation that the edit was persisted, without requiring a notification popup or a page refresh.
Query Editor: Compound Conditions and Aggregations
Session 263 also enhanced the Query Editor with two major features:
AND Operator in WHERE Clauses
The query parser gained support for compound conditions using the && operator:
// Before Session 263: single condition only
User.where(active == true)// After Session 263: compound conditions User.where(active == true && role == "admin") Product.where(price > 50 && stock > 0 && category == "electronics") ```
The implementation splits the condition string by &&, parses each sub-condition independently, and chains them in the query builder:
fn parse_where_conditions(condition_str: &str) -> Vec<WhereCondition> {
condition_str
.split("&&")
.map(|part| part.trim())
.map(|part| parse_single_condition(part))
.collect()
}fn execute_with_conditions(
db: &ZeroCore,
entity: &str,
conditions: &[WhereCondition],
) -> Vec
Aggregation Functions
Four aggregation methods were added to the query parser:
// Sum all values in a numeric field
Product.sum("price") // => 45230.50// Average across all records Order.avg("total") // => 89.99
// Minimum value Sensor.min("temperature") // => -12.5
// Maximum value Sensor.max("temperature") // => 42.3 ```
Each aggregation returns a single scalar value rather than a list of records. The query result display in the console adapts accordingly, showing a single value card instead of a records table.
Live Dashboard Statistics
The dashboard stats cards, which previously showed static counts, were connected to real-time data. The /_flin/api/stats endpoint now returns:
stats = {
entities_count: db.collection_names().len,
routes_count: compiled_routes.len,
records_count: db.total_record_count(),
database_size: db.size_on_disk(),
ai_model: flin.config.ai_model || "none"
}The records count is a live sum across all entity collections. The database size is a real scan of the .flindb/ directory. The AI model shows which provider is configured, giving developers instant visibility into their AI integration status.
The Compound Effect
None of these 12 features took more than an hour to implement individually. Column sorting: 30 minutes. Search with debounce: 20 minutes. Export: 40 minutes. Bulk delete: 30 minutes. Inline editing: 45 minutes.
But together, they transformed the entity browser from a tool that could technically browse data into a tool that developers actually preferred over their database clients. The search is faster than writing a WHERE clause. The inline edit is faster than opening a form. The export is faster than writing a script.
This is the power of incremental enhancement. Each feature is small. The combined effect is transformative.
The next article visits the storage and database admin views that give developers visibility into their application's data layer.
---
This is Part 142 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO turned 12 small enhancements into a database management revolution.
Series Navigation: - [141] Sidebar Navigation: A Small Fix That Changed Everything - [142] Entity Management Enhancements (you are here) - [143] Storage and Database Admin Views