airnest 0.1.2

Silent, async SQLite persistence for Rust. Derive once, store forever.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
# airnest ✈️

Silent, async SQLite persistence for Rust. Derive once, store forever.

```rust
#[persistent]
#[derive(Serialize, Deserialize, Clone)]
pub struct Session {
    pub messages: Vec<Message>,
    pub created_at: u64,
}

let store = Store::open("app.db").await?;
let session = Session::new(vec![], 0);
store.save(&session).await?;

let loaded = store.load(&session).await?; // Option<Session>
```

No schema files. No migrations. No SQL. Just `#[persistent]` and go.

---

## Install

```toml
[dependencies]
airnest = { version = "0.1.1" }
serde    = { version = "1", features = ["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.

```rust
use airnest::persistent;

#[persistent]                              // ← must be the outermost attribute
#[derive(Clone)]
pub struct WorkflowState {
    pub status: WorkflowStatus,
    pub steps:  Vec<Step>,
}

let state = WorkflowState::new(
    WorkflowStatus::Running,
    vec![],
);
println!("{:?}", state.id());   // 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:

```rust
// Persistent storage
let store = Store::open("agent.db").await?;

// In-memory (tests, ephemeral state)
let store = Store::in_memory().await?;
```

`Store` is cheap to clone — the underlying connection is `Arc`-wrapped. Pass it around freely.

### 3. Operations

```rust
// Upsert (insert or overwrite by embedded id)
store.save(&value).await?;

// Load by id — accepts AirId or &Value
let value = store.load(&existing_value).await?;
let value = store.load(existing_value.id()).await?;

// Delete — accepts AirId or &Value
store.delete(&existing_value).await?;

// Check existence — accepts AirId or &Value
if store.exists(&existing_value).await? { ... }

// Scan all values of a type, ordered by save time
let all: Vec<MyType> = store.scan::<MyType>().await?;

// Alias for scan — "load everything into memory"
let all: Vec<MyType> = store.load_all::<MyType>().await?;

// Count
let n: i64 = store.count::<MyType>().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

```rust
// Load → mutate → save in one call
store.update(&existing_value, |v| v.status = Status::Done).await?;
```

### 5. Atomic batch writes

Persist multiple values of different types in one SQLite transaction:

```rust
let mut batch = StoreBatch::new();
batch.push(&session)?;
batch.push(&workflow)?;
batch.push(&tool_context)?;
store.save_batch(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:

```rust
#[persistent(index(status, priority, created_at))]
#[derive(Serialize, Deserialize, Clone)]
pub struct Job {
    pub status:     String,   // "pending" | "running" | "done"
    pub priority:   i32,
    pub created_at: u64,
    pub payload:    Vec<u8>,  // not indexed — lives only in the blob
}
```

Each indexed field becomes a real SQLite `TEXT` column alongside the blob,
updated atomically on every `save`. Query them via `query_raw` or `pool()`:

```rust
// Typed helper — decodes the blob column automatically
let pending: Vec<Job> = store
    .query_raw::<Job>(
        r#"SELECT v FROM "Job"
           WHERE "status" = 'pending'
           ORDER BY "priority" ASC"#,
    )
    .await?;

// Full escape hatch — raw pool access for anything else
let count: i64 = sqlx::query_scalar(
        r#"SELECT COUNT(*) FROM "Job" WHERE "status" != 'done'"#,
    )
    .fetch_one(store.pool())
    .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

```rust
// V1 (already stored)
#[persistent]
#[derive(Serialize, Deserialize, Clone)]
pub struct Session {
    pub data: String,
}

// V2 — wrap the new field in Option
#[persistent]
#[derive(Serialize, Deserialize, Clone)]
pub struct Session {
    pub data: String,
    pub tags: Option<Vec<String>>,  // None for old blobs, Some(...) for new
}
```

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:

```rust
// One-time migration: re-encode all rows under the new schema
async fn migrate_sessions(store: &Store) -> Result<(), StoreError> {
    let all: Vec<SessionV1> = store.load_all::<SessionV1>().await?;
    for old in all {
        let new = SessionV2::from(old);
        store.save(&new).await?;
    }
    Ok(())
}
```

---

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

```text
src/
├── ui/
├── services/
├── engine/
└── persistence/
    ├── chat_session.rs
    ├── settings.rs
    └── workspace.rs
```

Only these get `#[persistent]`:

```rust
#[persistent(index(name))]
#[derive(Clone)]
pub struct Workspace {
    pub name: String,
    pub chats: Vec<ChatSession>,
}
```

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

```text
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:

```rust
#[persistent]
pub struct AppState {
    pub sessions: Vec<ChatSession>,
    pub settings: Settings,
    pub ui_state: UiState,
}
```

Then:

```rust
store.save(&state).await?;
```

Boom — whole app snapshot.

---

### Pattern 4 — Repository layer

Instead of calling `store` directly everywhere, wrap it:

```rust
pub struct SessionRepo {
    store: Store,
}

impl SessionRepo {
    pub async fn save(&self, session: &ChatSession) -> Result<(), StoreError> {
        self.store.save(session).await
    }

    pub async fn load(&self, id: AirId<ChatSession>) -> Result<Option<ChatSession>, StoreError> {
        self.store.load(id).await
    }
}
```

Business logic stays clean and the persistence boundary is explicit.

---

### Pattern 5 — Domain module convention

A very scalable convention:

```text
chat/
├── mod.rs
├── model.rs          // plain structs
└── persistence.rs    // #[persistent] roots
```

`model.rs`:

```rust
pub struct Message { ... }
pub struct ToolCall { ... }
pub struct StreamAccumulator { ... }
```

`persistence.rs`:

```rust
#[persistent]
pub struct ChatSession {
    pub messages: Vec<Message>,
}
```

---

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

```text
5–20 persistent structs
hundreds of normal structs
```

rather than hundreds of persistent structs. The crate is strongest when used
this way.

---

## Full example

```rust
use airnest::{persistent, Store, StoreBatch};
use serde::{Serialize, Deserialize};

#[persistent]
#[derive(Serialize, Deserialize, Clone)]
pub struct AgentSession {
    pub workflow_id: String,
    pub messages:    Vec<String>,
    pub created_at:  u64,
}

#[persistent(index(status))]
#[derive(Serialize, Deserialize, Clone)]
pub struct WorkflowRun {
    pub status: String,   // indexed — queryable without loading all blobs
    pub steps:  Vec<String>,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let store = Store::open("agent.db").await?;

    // Save
    let session = AgentSession::new("wf1".into(), vec![], 0);
    store.save(&session).await?;

    // Load — by value reference (ergonomic)
    let loaded = store.load(&session).await?;

    // Or by id explicitly
    let loaded = store.load(session.id()).await?;

    // Atomic multi-type write
    let run = WorkflowRun::new("running".into(), vec![]);
    let mut batch = StoreBatch::new();
    batch.push(&session)?;
    batch.push(&run)?;
    store.save_batch(batch).await?;

    // Query on indexed column
    let running: Vec<WorkflowRun> = store
        .query_raw::<WorkflowRun>(r#"SELECT v FROM "WorkflowRun" WHERE "status" = 'running'"#)
        .await?;

    println!("loaded: {:?}", loaded.map(|s| s.workflow_id));
    println!("running workflows: {}", running.len());
    Ok(())
}
```

---

## License

MIT OR Apache-2.0