airnest ✈️
Silent, async SQLite persistence for Rust. Derive once, store forever.
let store = open.await?;
let session = new;
store.save.await?;
let loaded = store.load.await?; // Option<Session>
No schema files. No migrations. No SQL. Just #[persistent] and go.
Install
[]
= { = "..." } # crates.io publish coming soon
= { = "1", = ["derive"] }
serde is required as a dependency because the macro generates
Serialize / Deserialize implementations for your structs.
Core concepts
1. #[persistent]
Mark any struct as persistable. The macro injects a UUIDv7 id field,
generates a new() constructor, and auto-derives Serialize and Deserialize
if they are not already present.
use persistent;
// ← must be the outermost attribute
let state = new;
println!; // AirId<WorkflowState>
#[persistent] must sit above #[derive(...)] so the id field exists before derives run.
You can still add #[derive(Serialize, Deserialize)] explicitly when you need custom serde
attributes such as #[serde(rename)] or #[serde(default)].
2. Store
One store, one file, all types:
// Persistent storage
let store = open.await?;
// In-memory (tests, ephemeral state)
let store = in_memory.await?;
Store is cheap to clone — the underlying connection is Arc-wrapped. Pass it around freely.
3. Operations
// Upsert (insert or overwrite by embedded id)
store.save.await?;
// Load by id — accepts AirId or &Value
let value = store.load.await?;
let value = store.load.await?;
// Delete — accepts AirId or &Value
store.delete.await?;
// Check existence — accepts AirId or &Value
if store.exists.await?
// Scan all values of a type, ordered by save time
let all: = store..await?;
// Alias for scan — "load everything into memory"
let all: = store..await?;
// Count
let n: i64 = store..await?;
load, delete, exists, and update all accept either an AirId<T> or a &T. The latter reads the embedded id automatically, so you rarely need to thread .id() through your code.
4. Convenience helpers
// Load → mutate → save in one call
store.update.await?;
5. Atomic batch writes
Persist multiple values of different types in one SQLite transaction:
let mut batch = new;
batch.push?;
batch.push?;
batch.push?;
store.save_batch.await?;
If any push fails (encode error), the batch is never committed.
Indexed columns
By default, the full struct is stored as a compact binary blob. If you need to query across values — filter by status, sort by priority, range by timestamp — declare index fields:
Each indexed field becomes a real SQLite TEXT column alongside the blob,
updated atomically on every save. Query them via query_raw or pool():
// Typed helper — decodes the blob column automatically
let pending: = store
.
.await?;
// Full escape hatch — raw pool access for anything else
let count: i64 = query_scalar
.fetch_one
.await?;
Any type that implements Display can be an index column: String, i32,
u64, bool, custom enums with Display, etc.
Schema evolution
airnest uses bitcode — a bitwise binary serialization format. This means:
| Change | Strategy |
|---|---|
| Add a field | Wrap it in Option<T>. Old blobs decode to None. |
| Remove a field | Write a migration (load old, re-save new). |
| Rename a field | No impact — bitcode encodes by position, not name. |
| Change a field type | Write a migration. |
Adding a field
// V1 (already stored)
// V2 — wrap the new field in Option
Old blobs decode as tags: None. New saves carry the value. No migration needed.
Writing a migration
For breaking changes, run a migration at startup:
// One-time migration: re-encode all rows under the new schema
async
Design notes
Why not sqlx? The current crates.io index requires Rust ≥ 1.85 for sqlx's
transitive dependencies. airnest uses rusqlite (bundled SQLite, zero system
deps) and tokio::task::spawn_blocking to keep the async contract without
requiring a bleeding-edge toolchain.
Why bitcode? It's a very compact, fast binary serialization format for Rust — competitive with or smaller than bincode and faster than JSON, MessagePack, or CBOR. For agent state (large message histories, tool call logs) this matters. The tradeoff is positional encoding; see schema evolution above.
Why one file? One SQLite WAL file is simpler to back up, replicate, and reason about than a directory of files. WAL mode means readers never block writers, so an agent streaming output can read session state concurrently with the loop writing tool results.
Hybrid blobs + indexed columns gives you the best of both worlds: compact storage and schema freedom for the struct body, real SQLite indexes for the fields you actually query. You only pay the column overhead where you need it.
Architecture patterns for large codebases
Don't make everything persistent. Persist aggregates / domain entities.
If you try to slap #[persistent] on every struct, you’ll create a nightmare.
Think in layers. Only the structs that represent state worth saving should
carry the attribute. Child structs nested inside a persistent root should be
plain Serialize + Deserialize values.
Pattern 1 — Persistence boundary (recommended)
Create a persistence/ folder and keep persistence decisions localized:
src/
├── ui/
├── services/
├── engine/
└── persistence/
├── chat_session.rs
├── settings.rs
└── workspace.rs
Only these get #[persistent]:
ChatSession, Message, ToolCall, and StreamAccumulator inside are plain
serde structs — no nested persistence needed. This scales extremely well.
Pattern 2 — Aggregate root model
Persist only the "root" of an aggregate:
Workspace
└── ChatSession
└── Message
└── ToolCall
Only Workspace (or ChatSession) is #[persistent]. Everything below is
plain serde. This keeps your DB simple and your mental model clean.
Pattern 3 — Save application state
For desktop apps, editors, AI clients, games, or local-first apps, a single snapshot struct is often easiest:
Then:
store.save.await?;
Boom — whole app snapshot.
Pattern 4 — Repository layer
Instead of calling store directly everywhere, wrap it:
Business logic stays clean and the persistence boundary is explicit.
Pattern 5 — Domain module convention
A very scalable convention:
chat/
├── mod.rs
├── model.rs // plain structs
└── persistence.rs // #[persistent] roots
model.rs:
persistence.rs:
A heuristic for deciding persistence
Ask:
"Would I ever independently load/save this?"
If yes → #[persistent]
If no → plain serde
For a large app, aim for:
5–20 persistent structs
hundreds of normal structs
rather than hundreds of persistent structs. The crate is strongest when used this way.
Full example
use ;
use ;
async
License
MIT OR Apache-2.0