Every application ever built needs four operations: create, read, update, delete. These operations are so fundamental that the acronym CRUD has become industry shorthand for "basic data management." Yet in 2026, performing these four operations still requires learning SQL, configuring an ORM, or chaining together method calls on a query builder that translates your intent into SQL strings that get sent to a database server that parses them back into operations.
FlinDB does CRUD differently. There is no SQL. There is no ORM. There is no translation layer. You save an entity, and it is saved. You find an entity, and it is found. The operations map directly to the language, and the language maps directly to the storage engine.
This is the story of Session 160, where we implemented the complete CRUD layer for FlinDB -- 37 tests, 380 lines of Rust, and a developer experience that makes database operations feel like variable assignment.
Create: Save and Go
Creating data in FlinDB uses the save keyword. Not insert. Not create. Not db.collection.insertOne(). Just save.
// Create and save in two steps
user = User { name: "Thales", email: "[email protected]" }
save user// Or as a one-liner save User { name: "Thales", email: "[email protected]" } ```
After the save, the entity has been persisted to disk through the WAL, assigned a unique ID, and given system fields:
user.id // 1
user.created_at // 2026-01-13T10:00:00Z
user.version // 1The Rust implementation behind save is where the real work happens. When the FLIN VM executes a save operation, it calls into ZeroCore:
pub fn save(
&mut self,
entity_type: &str,
id: Option<u64>,
fields: HashMap<String, Value>,
) -> DatabaseResult<EntityInstance> {
let schema = self.get_schema(entity_type)?;// Validate all constraints schema.validate(&fields)?; self.check_unique_constraints(entity_type, id, &schema, &fields)?;
// Determine if this is a create or update match id { None => self.create_entity(entity_type, &schema, fields), Some(id) => self.update_entity(entity_type, id, fields), } } ```
The save method handles both creation and updates. If the entity has no ID, it is a new entity -- ZeroCore generates an ID, creates the initial version, and inserts it into storage. If the entity already has an ID, it is an update -- ZeroCore creates a new version, preserving the old one in the history.
This dual behavior is why FLIN uses save instead of separate create and update commands. From the developer's perspective, you make changes to an entity and save it. Whether it is new or existing is an implementation detail.
Read: Find, All, Where, First
Reading data in FlinDB offers four patterns, each designed for a specific use case.
Find by ID is the most direct. You know the ID, you get the entity:
user = User.find(1)Under the hood, this is an O(1) lookup in ZeroCore's data HashMap:
pub fn find_by_id(
&self,
entity_type: &str,
id: u64,
) -> DatabaseResult<EntityInstance> {
let collection = self.data.get(entity_type)
.ok_or(DatabaseError::EntityTypeNotFound)?;let versions = collection.get(&id) .ok_or(DatabaseError::NotFound(id))?;
// Return the latest non-deleted version versions.last() .filter(|v| v.deleted_at.is_none()) .cloned() .ok_or(DatabaseError::NotFound(id)) } ```
All returns every entity of a type:
users = User.allWhere filters entities by conditions:
// Simple condition
active_users = User.where(active == true)// Multiple conditions admins = User.where(role == "admin" && active == true)
// Comparison operators expensive = Product.where(price > 1000) recent = Post.where(created_at > yesterday) ```
First returns the first entity matching a condition:
admin = User.first(role == "admin")Count returns the number of entities, optionally filtered:
total = User.count
active = User.count(active == true)The count_where method in ZeroCore uses a predicate-based approach that avoids materializing the full result set:
pub fn count_where<F>(
&self,
entity_type: &str,
predicate: F,
) -> DatabaseResult<usize>
where
F: Fn(&EntityInstance) -> bool,
{
let collection = self.data.get(entity_type)
.ok_or(DatabaseError::EntityTypeNotFound)?;Ok(collection.values() .filter_map(|versions| versions.last()) .filter(|v| v.deleted_at.is_none()) .filter(|v| predicate(v)) .count()) } ```
Query Chaining
FlinDB queries are chainable. You can combine filtering, ordering, and limiting in a fluent API:
results = Product
.where(category == "electronics")
.where(price < 1000)
.order(rating, desc)
.limit(10)This reads like natural language: "Give me products in the electronics category, priced under 1000, ordered by rating descending, limited to 10 results."
The query builder in Rust accumulates conditions and executes them in a single pass:
db.query("Product")
.where_eq("category", Value::Text("electronics".into()))
.where_lt("price", Value::Int(1000))
.order_by_desc("rating")
.limit(10)
.execute()?;Ordering supports multiple fields:
sorted = Product.all.order(category, asc).order(price, desc)Pagination is built in:
page2 = User.all.limit(20).offset(20)These operations compose naturally because the query builder returns itself after each method call, allowing indefinite chaining until execute() is called.
Update: Modify and Save
Updating an entity in FlinDB is identical to creating one -- you modify fields and call save:
user = User.find(1)
user.name = "New Name"
save userThis simplicity hides important behavior. When save is called on an existing entity:
1. The current state becomes a historical version
2. The new state becomes the current version
3. The version number increments
4. The updated_at timestamp is set to now
5. The WAL receives a new entry
The developer never thinks about version management. They change a field and save. The temporal model handles everything else.
Delete: Soft by Default
FlinDB's delete operation is soft by default:
user = User.find(1)
delete userSoft deletion means the entity is marked with a deleted_at timestamp but remains in storage. It disappears from queries -- User.all will not return it, User.count will not count it, User.where(...) will skip it. But it can be recovered:
// The entity still exists in the database
// It just does not appear in queriesAll queries in ZeroCore automatically filter out soft-deleted entities:
// In every query method:
.filter(|v| v.deleted_at.is_none())This filter is applied at the engine level, not the query level. There is no way for a developer to accidentally include deleted entities in a query result. The engine guarantees that deleted entities are invisible unless explicitly accessed through their ID.
For permanent removal, FLIN provides destroy:
user = User.find(1)
destroy userdestroy permanently removes the entity and all its version history. This is irreversible and intended for compliance scenarios (GDPR right to erasure) or storage reclamation.
The Integration Test Suite
Session 160 was not just about implementing CRUD. It was about proving that the implementation was correct. We wrote 14 comprehensive integration tests that exercise every aspect of the CRUD layer:
// DB9-01: Entity creation
#[test]
fn test_flindb_entity_creation() { /* ... */ }// DB9-02: Save assigns ID #[test] fn test_flindb_save_assigns_id() { / ... / }
// DB9-03: Find by ID #[test] fn test_flindb_find_by_id() { / ... / }
// DB9-04: Delete (soft delete) #[test] fn test_flindb_delete_soft() { / ... / }
// DB9-05: Where clauses #[test] fn test_flindb_where_equality() { / ... / }
// DB9-06: Query ordering #[test] fn test_flindb_order_by() { / ... / }
// DB9-07: Pagination #[test] fn test_flindb_pagination() { / ... / }
// DB9-08: Count operation #[test] fn test_flindb_count() { / ... / }
// DB9-09: Chained queries #[test] fn test_flindb_chained_query() { / ... / }
// DB9-10: History property #[test] fn test_flindb_history_property() { / ... / } ```
The last four tests were particularly important. They tested real-world scenarios: a blog application (User + Post entities), an e-commerce application (Product + Order entities), a todo application (filter by completion status), and combined first/all queries. These were not unit tests of individual methods -- they were end-to-end tests of the entire CRUD pipeline, from entity creation through querying and deletion.
The delete operation tests were especially thorough:
test_delete_preserves_all_history-- verifies that soft delete keeps version history intacttest_delete_vs_destroy_history_difference-- verifies the behavioral difference between delete and destroytest_delete_find_excludes_deleted-- verifies thatfinddoes not return soft-deleted entitiestest_delete_all_excludes_deleted-- verifies thatallexcludes soft-deleted entitiestest_delete_query_excludes_deleted-- verifies thatwherequeries exclude soft-deleted entitiestest_restore_after_delete-- verifies that soft-deleted entities can be restored
Six tests for one operation. This level of coverage was necessary because delete semantics are where most database abstractions break down. The boundary between "deleted" and "not deleted" touches every query path, and a bug here would be invisible until production data started disappearing from results or, worse, appearing when it should not.
The Developer Experience Gap
To understand why CRUD without SQL matters, consider the cognitive load difference.
In a typical Node.js application with Prisma:
// Create
const user = await prisma.user.create({
data: {
name: "Thales",
email: "[email protected]",
},
});// Read const users = await prisma.user.findMany({ where: { active: true, }, orderBy: { createdAt: 'desc', }, take: 10, });
// Update await prisma.user.update({ where: { id: 1 }, data: { name: "New Name" }, });
// Delete await prisma.user.delete({ where: { id: 1 }, }); ```
This is already one of the better developer experiences in the SQL ecosystem. But count the concepts: prisma client, create/findMany/update/delete methods, data/where/orderBy/take parameters, await for async, nested objects for conditions. A developer must understand all of these before writing their first CRUD operation.
In FLIN:
// Create
user = User { name: "Thales", email: "[email protected]" }
save user// Read users = User.where(active == true).order(created_at, desc).limit(10)
// Update user = User.find(1) user.name = "New Name" save user
// Delete delete User.find(1) ```
The FLIN version reads like pseudocode. There is no client to import. No async/await to manage. No nested configuration objects. The operations are verbs in the language, not methods on a library. A developer who has never seen FLIN before can read this code and understand what it does.
That is the point. CRUD is not the interesting part of application development. It is the plumbing. FlinDB makes the plumbing disappear so developers can focus on the parts of their application that actually matter.
---
This is Part 3 of the "How We Built FlinDB" series, documenting how we built a complete embedded database engine for the FLIN programming language.
Series Navigation: - [056] FlinDB: Zero-Configuration Embedded Database - [057] Entities, Not Tables: How FlinDB Thinks About Data - [058] CRUD Without SQL (you are here) - [059] Constraints and Validation in FlinDB - [060] Aggregations and Analytics