The query-based RTDB uses redb, a lightweight embedded database, to provide Firebase-like functionality with minimal overhead. This approach is ideal for structured data, complex queries, and traditional database operations.
Cloudillo uses redb, a lightweight pure-Rust embedded database with ACID transactions and zero-copy reads.
The core interface for database operations. All methods are tenant-aware (tn_id parameter). Write operations go through the separate Transaction trait.
#[async_trait]pubtrait RtdbAdapter: Debug+ Send + Sync {
/// Begin a new transaction for write operations
asyncfntransaction(&self, tn_id: TnId, db_id: &str) -> ClResult<Box<dyn Transaction>>;
/// Close a database instance, flushing pending changes
asyncfnclose_db(&self, tn_id: TnId, db_id: &str) -> ClResult<()>;
/// Query documents with optional filtering, sorting, and pagination
asyncfnquery(&self, tn_id: TnId, db_id: &str, path: &str, opts: QueryOptions)
-> ClResult<Vec<Value>>;
/// Get a single document at a specific path
asyncfnget(&self, tn_id: TnId, db_id: &str, path: &str) -> ClResult<Option<Value>>;
/// Subscribe to real-time changes (returns a stream of ChangeEvents)
asyncfnsubscribe(&self, tn_id: TnId, db_id: &str, opts: SubscriptionOptions)
-> ClResult<Pin<Box<dyn Stream<Item = ChangeEvent>+ Send>>>;
/// Create an index on a field for query performance
asyncfncreate_index(&self, tn_id: TnId, db_id: &str, path: &str, field: &str)
-> ClResult<()>;
/// Get database statistics (size, record count, table count)
asyncfnstats(&self, tn_id: TnId, db_id: &str) -> ClResult<DbStats>;
/// Export all documents from a database
asyncfnexport_all(&self, tn_id: TnId, db_id: &str) -> ClResult<Vec<(Box<str>, Value)>>;
/// Acquire a lock on a document path
asyncfnacquire_lock(&self, tn_id: TnId, db_id: &str, path: &str,
user_id: &str, mode: LockMode, conn_id: &str) -> ClResult<Option<LockInfo>>;
/// Release a lock on a document path
asyncfnrelease_lock(&self, tn_id: TnId, db_id: &str, path: &str,
user_id: &str, conn_id: &str) -> ClResult<()>;
/// Check if a path has an active lock
asyncfncheck_lock(&self, tn_id: TnId, db_id: &str, path: &str)
-> ClResult<Option<LockInfo>>;
/// Release all locks held by a specific user (on disconnect)
asyncfnrelease_all_locks(&self, tn_id: TnId, db_id: &str,
user_id: &str, conn_id: &str) -> ClResult<()>;
}
Transaction Trait
All write operations (create, update, delete) are performed within a transaction:
#[async_trait]pubtrait Transaction: Send + Sync {
/// Create a new document with auto-generated ID
asyncfncreate(&mut self, path: &str, data: Value) -> ClResult<Box<str>>;
/// Update an existing document (full replacement)
asyncfnupdate(&mut self, path: &str, data: Value) -> ClResult<()>;
/// Delete a document at a path
asyncfndelete(&mut self, path: &str) -> ClResult<()>;
/// Read a document (with read-your-own-writes semantics)
asyncfnget(&self, path: &str) -> ClResult<Option<Value>>;
/// Commit all changes atomically
asyncfncommit(&mut self) -> ClResult<()>;
/// Rollback all changes
asyncfnrollback(&mut self) -> ClResult<()>;
}
Data Model
Collections and Documents
Data is organized into collections containing JSON documents:
Documents are JSON objects. On create, the server generates a random ID and injects it into the document as the id field; the same id is also returned to the caller and is injected at read time if missing.
The only auto-managed field is id. Timestamps are not added automatically — use the { "$fn": "now" } computed value if you want a creation or update timestamp on a document.
Path Syntax
Paths use slash-separated segments:
users // Collection
users/user_001 // Specific document
posts/post_abc/comments // Sub-collection
Storage Schema (redb)
Each database is backed by a redb file containing three string-keyed tables:
Table
Purpose
Key format
docs
Document JSON
{db_id}/{path}
idxs
Secondary-index entries
{collection}/_idx/{field}/{value}/{doc_id}
meta
Index definitions and bookkeeping
{collection}/_meta/indexes
Values are JSON strings (index entries store an empty value — the key itself encodes the indexed field, value, and document ID). When a single redb file is shared across tenants, the tenant ID is prepended to every key ({tn_id}/{db_id}/{path}).
QueryFilter is a flat struct (not an enum) where each field is a HashMap<String, Value>. Multiple conditions within the struct are ANDed implicitly — a document must satisfy all specified constraints. All field names use camelCase serialization.
All write operations (create, update, replace, delete) must be wrapped in a transaction message. There are no standalone write message types. The update operation merges fields into the existing document, while replace does a full document replacement.
Client sends subscribe message
↓
Server validates permissions
↓
Server creates broadcast channel
↓
Server executes initial query
↓
Server sends subscribeResult with data
↓
Server watches for changes matching filter
↓
On change: Server sends change event
↓
Client updates local state
Implementation
Subscription Structure:
id: Unique subscription identifier
path: Collection path being subscribed to
filter: Optional query filter to match changes
sender: Broadcast channel for sending change events
Change Event Types
ChangeEvent is a tagged enum with #[serde(tag = "action")] serialization:
Second operation references $post, replaced with actual ID
Comment gets correct post ID even though it wasn’t known initially
Document Locking
The RTDB supports document-level locking for exclusive or advisory editing access.
Lock Modes
Soft lock (advisory): Other clients can still write but receive a notification that the document is locked. Useful for signaling editing intent.
Hard lock (enforced): The server rejects writes from other clients while the lock is held. Only the lock holder (identified by conn_id) can modify the document.
Locks expire automatically after a TTL (time-to-live) period. This prevents permanently locked documents when clients disconnect unexpectedly or crash without releasing their locks. The server cleans up expired locks during its periodic maintenance cycle.
Connection-Based Echo Suppression
The server tracks lock ownership by conn_id. When a lock change event is broadcast to subscribers, the originating connection is excluded from the notification (echo suppression), similar to how write operations suppress echoes. This prevents the client that acquired the lock from receiving its own lock notification.
Lock Status in Change Events
Active subscriptions receive lock/unlock events as part of the change stream:
When aggregate is used with a subscribe message, the server computes aggregates incrementally. On each change event that affects the subscribed path and filter, the server recalculates the affected groups and sends an updated aggregate snapshot rather than the full document set. This keeps aggregate subscriptions efficient even for large collections.
Each index produces idxs-table entries keyed {collection}/_idx/{field}/{value}/{doc_id}. Array fields are expanded: one entry is written per scalar element, so an index over a tag array can answer arrayContains queries. The set of indexed fields per collection is persisted in the meta table and reloaded when the database instance is opened.
Note
There are no compound (multi-field) indexes and no unique constraints — createIndex indexes one field at a time. Queries with multiple conditions can use an index for one field and filter the rest in memory; queries without a matching index fall back to a full collection scan.