Skip to main content

mnemo_core/storage/
duckdb.rs

1use std::path::Path;
2use std::sync::Arc;
3use tokio::sync::Mutex;
4
5use crate::error::{Error, Result};
6use crate::model::acl::{Acl, Permission};
7use crate::model::agent_profile::AgentProfile;
8use crate::model::checkpoint::Checkpoint;
9use crate::model::delegation::{Delegation, DelegationScope};
10use crate::model::embedding_baseline::EmbeddingBaseline;
11use crate::model::event::AgentEvent;
12use crate::model::memory::MemoryRecord;
13use crate::model::relation::Relation;
14use crate::model::write_provenance::{
15    WriteOp, WriteProvenance, flags_from_storage, flags_to_storage,
16};
17use crate::storage::{MemoryFilter, StorageBackend};
18use uuid::Uuid;
19
20pub struct DuckDbStorage {
21    conn: Arc<Mutex<duckdb::Connection>>,
22}
23
24impl DuckDbStorage {
25    pub fn open(path: &Path) -> Result<Self> {
26        let conn = duckdb::Connection::open(path)?;
27        super::migrations::run_migrations(&conn)?;
28        Ok(Self {
29            conn: Arc::new(Mutex::new(conn)),
30        })
31    }
32
33    pub fn open_in_memory() -> Result<Self> {
34        let conn = duckdb::Connection::open_in_memory()?;
35        super::migrations::run_migrations(&conn)?;
36        Ok(Self {
37            conn: Arc::new(Mutex::new(conn)),
38        })
39    }
40}
41
42fn serialize_embedding(embedding: &Option<Vec<f32>>) -> Option<Vec<u8>> {
43    embedding
44        .as_ref()
45        .map(|v| v.iter().flat_map(|f| f.to_le_bytes()).collect())
46}
47
48fn deserialize_embedding(blob: Option<Vec<u8>>) -> Option<Vec<f32>> {
49    blob.map(|bytes| {
50        bytes
51            .chunks_exact(4)
52            .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
53            .collect()
54    })
55}
56
57fn row_to_memory(row: &duckdb::Row<'_>) -> duckdb::Result<MemoryRecord> {
58    let id_str: String = row.get(0)?;
59    let tags_json: Option<String> = row.get(6)?;
60    let metadata_json: Option<String> = row.get(7)?;
61    let embedding_blob: Option<Vec<u8>> = row.get(8)?;
62    let content_hash: Vec<u8> = row.get(9)?;
63    let prev_hash: Option<Vec<u8>> = row.get(10)?;
64
65    let memory_type_str: String = row.get(3)?;
66    let scope_str: String = row.get(4)?;
67    let source_type_str: String = row.get(11)?;
68    let consolidation_state_str: String = row.get(13)?;
69
70    Ok(MemoryRecord {
71        id: Uuid::parse_str(&id_str)
72            .map_err(|e| duckdb::Error::FromSqlConversionFailure(0, duckdb::types::Type::Text, Box::new(e)))?,
73        agent_id: row.get(1)?,
74        content: row.get(2)?,
75        memory_type: memory_type_str.parse()
76            .map_err(|e: Error| duckdb::Error::FromSqlConversionFailure(3, duckdb::types::Type::Text, e.to_string().into()))?,
77        scope: scope_str.parse()
78            .map_err(|e: Error| duckdb::Error::FromSqlConversionFailure(4, duckdb::types::Type::Text, e.to_string().into()))?,
79        importance: row.get(5)?,
80        tags: match tags_json {
81            Some(ref s) => serde_json::from_str(s).unwrap_or_else(|e| {
82                tracing::warn!(id = %id_str, error = %e, raw = %s, "corrupted tags JSON, defaulting to empty");
83                vec![]
84            }),
85            None => vec![],
86        },
87        metadata: match metadata_json {
88            Some(ref s) => serde_json::from_str(s).unwrap_or_else(|e| {
89                tracing::warn!(id = %id_str, error = %e, "corrupted metadata JSON, defaulting to empty");
90                serde_json::Value::Object(serde_json::Map::new())
91            }),
92            None => serde_json::Value::Object(serde_json::Map::new()),
93        },
94        embedding: deserialize_embedding(embedding_blob),
95        content_hash,
96        prev_hash,
97        source_type: source_type_str.parse()
98            .map_err(|e: Error| duckdb::Error::FromSqlConversionFailure(11, duckdb::types::Type::Text, e.to_string().into()))?,
99        source_id: row.get(12)?,
100        consolidation_state: consolidation_state_str.parse()
101            .map_err(|e: Error| duckdb::Error::FromSqlConversionFailure(13, duckdb::types::Type::Text, e.to_string().into()))?,
102        access_count: u64::try_from(row.get::<_, i64>(14)?).unwrap_or(0),
103        org_id: row.get(15)?,
104        thread_id: row.get(16)?,
105        created_at: row.get(17)?,
106        updated_at: row.get(18)?,
107        last_accessed_at: row.get(19)?,
108        expires_at: row.get(20)?,
109        deleted_at: row.get(21)?,
110        decay_rate: row.get(22)?,
111        created_by: row.get(23)?,
112        version: u32::try_from(row.get::<_, i32>(24)?).unwrap_or(1),
113        prev_version_id: match row.get::<_, Option<String>>(25)? {
114            Some(s) => Uuid::parse_str(&s).map_err(|e| {
115                tracing::warn!(memory_id = %id_str, error = %e, "corrupted prev_version_id UUID");
116                e
117            }).ok(),
118            None => None,
119        },
120        quarantined: row.get::<_, bool>(26)?,
121        quarantine_reason: row.get(27)?,
122        decay_function: row.get(28).unwrap_or(None),
123    })
124}
125
126#[async_trait::async_trait]
127impl StorageBackend for DuckDbStorage {
128    fn backend_name(&self) -> &'static str {
129        "duckdb"
130    }
131
132    fn records_write_provenance(&self) -> bool {
133        true
134    }
135
136    async fn insert_write_provenance(&self, prov: &WriteProvenance) -> Result<()> {
137        let conn = self.conn.lock().await;
138        conn.execute(
139            "INSERT INTO write_provenance (id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
140            duckdb::params![
141                prov.id.to_string(),
142                prov.memory_id.to_string(),
143                prov.principal,
144                prov.capability_id.map(|id| id.to_string()),
145                prov.session_id,
146                prov.op.as_str(),
147                prov.authored_at.to_rfc3339(),
148                flags_to_storage(&prov.flags),
149                prov.prev_hash,
150                prov.content_hash,
151            ],
152        )?;
153        Ok(())
154    }
155
156    async fn get_write_provenance(&self, memory_id: Uuid) -> Result<Option<WriteProvenance>> {
157        let conn = self.conn.lock().await;
158        let mut stmt = conn.prepare(
159            "SELECT id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash \
160             FROM write_provenance WHERE memory_id = ? ORDER BY id DESC LIMIT 1",
161        )?;
162        let mut rows = stmt.query_map(
163            duckdb::params![memory_id.to_string()],
164            row_to_write_provenance,
165        )?;
166        match rows.next() {
167            Some(r) => Ok(Some(r.map_err(|e| Error::Storage(e.to_string()))?)),
168            None => Ok(None),
169        }
170    }
171
172    async fn get_latest_provenance_hash(&self) -> Result<Option<Vec<u8>>> {
173        let conn = self.conn.lock().await;
174        // UUID v7 ids are time-ordered, so `id DESC` is the append order — the
175        // chain head is the most recently inserted record.
176        let mut stmt =
177            conn.prepare("SELECT content_hash FROM write_provenance ORDER BY id DESC LIMIT 1")?;
178        let mut rows = stmt.query_map([], |row| row.get::<_, Vec<u8>>(0))?;
179        match rows.next() {
180            Some(r) => Ok(Some(r.map_err(|e| Error::Storage(e.to_string()))?)),
181            None => Ok(None),
182        }
183    }
184
185    async fn list_provenance_by_principal(
186        &self,
187        principal: &str,
188        limit: usize,
189    ) -> Result<Vec<WriteProvenance>> {
190        let conn = self.conn.lock().await;
191        let mut stmt = conn.prepare(
192            "SELECT id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash \
193             FROM write_provenance WHERE principal = ? ORDER BY id DESC LIMIT ?",
194        )?;
195        let rows = stmt.query_map(
196            duckdb::params![principal, limit as i64],
197            row_to_write_provenance,
198        )?;
199        let mut out = Vec::new();
200        for r in rows {
201            out.push(r.map_err(|e| Error::Storage(e.to_string()))?);
202        }
203        Ok(out)
204    }
205
206    async fn list_provenance_by_session(
207        &self,
208        session_id: &str,
209        limit: usize,
210    ) -> Result<Vec<WriteProvenance>> {
211        let conn = self.conn.lock().await;
212        let mut stmt = conn.prepare(
213            "SELECT id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash \
214             FROM write_provenance WHERE session_id = ? ORDER BY id DESC LIMIT ?",
215        )?;
216        let rows = stmt.query_map(
217            duckdb::params![session_id, limit as i64],
218            row_to_write_provenance,
219        )?;
220        let mut out = Vec::new();
221        for r in rows {
222            out.push(r.map_err(|e| Error::Storage(e.to_string()))?);
223        }
224        Ok(out)
225    }
226
227    async fn list_memory_ids_by_principal(&self, principal: &str) -> Result<Vec<Uuid>> {
228        let conn = self.conn.lock().await;
229        // Only `remember` writes create a memory; `share` grants access to an
230        // existing one. FORGET BY PROVENANCE by principal revokes what the
231        // principal actually authored.
232        let mut stmt = conn.prepare(
233            "SELECT DISTINCT memory_id FROM write_provenance WHERE principal = ? AND op = 'remember'",
234        )?;
235        let rows = stmt.query_map(duckdb::params![principal], |row| row.get::<_, String>(0))?;
236        collect_memory_ids(rows)
237    }
238
239    async fn list_memory_ids_by_session(&self, session_id: &str) -> Result<Vec<Uuid>> {
240        let conn = self.conn.lock().await;
241        let mut stmt = conn.prepare(
242            "SELECT DISTINCT memory_id FROM write_provenance WHERE session_id = ? AND op = 'remember'",
243        )?;
244        let rows = stmt.query_map(duckdb::params![session_id], |row| row.get::<_, String>(0))?;
245        collect_memory_ids(rows)
246    }
247
248    async fn list_all_provenance(&self, limit: usize) -> Result<Vec<WriteProvenance>> {
249        let conn = self.conn.lock().await;
250        let mut stmt = conn.prepare(
251            "SELECT id, memory_id, principal, capability_id, session_id, op, authored_at, flags, prev_hash, content_hash \
252             FROM write_provenance ORDER BY id ASC LIMIT ?",
253        )?;
254        let rows = stmt.query_map(duckdb::params![limit as i64], row_to_write_provenance)?;
255        let mut out = Vec::new();
256        for r in rows {
257            out.push(r.map_err(|e| Error::Storage(e.to_string()))?);
258        }
259        Ok(out)
260    }
261
262    async fn insert_memory(&self, record: &MemoryRecord) -> Result<()> {
263        let conn = self.conn.lock().await;
264        let tags_json = serde_json::to_string(&record.tags)?;
265        let metadata_json = serde_json::to_string(&record.metadata)?;
266        let embedding_blob = serialize_embedding(&record.embedding);
267
268        conn.execute(
269            "INSERT INTO memories (id, agent_id, content, memory_type, scope, importance, tags, metadata, embedding, content_hash, prev_hash, source_type, source_id, consolidation_state, access_count, org_id, thread_id, created_at, updated_at, last_accessed_at, expires_at, deleted_at, decay_rate, created_by, version, prev_version_id, quarantined, quarantine_reason, decay_function) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
270            duckdb::params![
271                record.id.to_string(),
272                record.agent_id,
273                record.content,
274                record.memory_type.to_string(),
275                record.scope.to_string(),
276                record.importance,
277                tags_json,
278                metadata_json,
279                embedding_blob,
280                record.content_hash,
281                record.prev_hash,
282                record.source_type.to_string(),
283                record.source_id,
284                record.consolidation_state.to_string(),
285                record.access_count as i64,
286                record.org_id,
287                record.thread_id,
288                record.created_at,
289                record.updated_at,
290                record.last_accessed_at,
291                record.expires_at,
292                record.deleted_at,
293                record.decay_rate,
294                record.created_by,
295                record.version as i32,
296                record.prev_version_id.map(|id| id.to_string()),
297                record.quarantined,
298                record.quarantine_reason,
299                record.decay_function,
300            ],
301        )?;
302        Ok(())
303    }
304
305    async fn get_memory(&self, id: Uuid) -> Result<Option<MemoryRecord>> {
306        let conn = self.conn.lock().await;
307        let mut stmt = conn.prepare(
308            "SELECT id, agent_id, content, memory_type, scope, importance, tags, metadata, embedding, content_hash, prev_hash, source_type, source_id, consolidation_state, access_count, org_id, thread_id, created_at, updated_at, last_accessed_at, expires_at, deleted_at, decay_rate, created_by, version, prev_version_id, quarantined, quarantine_reason, decay_function FROM memories WHERE id = ?",
309        )?;
310        let result = stmt.query_row([id.to_string()], row_to_memory);
311        match result {
312            Ok(record) => Ok(Some(record)),
313            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
314            Err(e) => Err(Error::Storage(e.to_string())),
315        }
316    }
317
318    async fn update_memory(&self, record: &MemoryRecord) -> Result<()> {
319        let conn = self.conn.lock().await;
320        let tags_json = serde_json::to_string(&record.tags)?;
321        let metadata_json = serde_json::to_string(&record.metadata)?;
322        let embedding_blob = serialize_embedding(&record.embedding);
323
324        let affected = conn.execute(
325            "UPDATE memories SET agent_id=?, content=?, memory_type=?, scope=?, importance=?, tags=?, metadata=?, embedding=?, content_hash=?, prev_hash=?, source_type=?, source_id=?, consolidation_state=?, access_count=?, org_id=?, thread_id=?, updated_at=?, last_accessed_at=?, expires_at=?, deleted_at=?, decay_rate=?, created_by=?, version=?, prev_version_id=?, quarantined=?, quarantine_reason=?, decay_function=? WHERE id=?",
326            duckdb::params![
327                record.agent_id,
328                record.content,
329                record.memory_type.to_string(),
330                record.scope.to_string(),
331                record.importance,
332                tags_json,
333                metadata_json,
334                embedding_blob,
335                record.content_hash,
336                record.prev_hash,
337                record.source_type.to_string(),
338                record.source_id,
339                record.consolidation_state.to_string(),
340                record.access_count as i64,
341                record.org_id,
342                record.thread_id,
343                record.updated_at,
344                record.last_accessed_at,
345                record.expires_at,
346                record.deleted_at,
347                record.decay_rate,
348                record.created_by,
349                record.version as i32,
350                record.prev_version_id.map(|id| id.to_string()),
351                record.quarantined,
352                record.quarantine_reason,
353                record.decay_function,
354                record.id.to_string(),
355            ],
356        )?;
357        if affected == 0 {
358            return Err(Error::NotFound(format!("memory {} not found", record.id)));
359        }
360        Ok(())
361    }
362
363    async fn soft_delete_memory(&self, id: Uuid) -> Result<()> {
364        let conn = self.conn.lock().await;
365        let now = chrono::Utc::now().to_rfc3339();
366        let affected = conn.execute(
367            "UPDATE memories SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL",
368            duckdb::params![now, now, id.to_string()],
369        )?;
370        if affected == 0 {
371            return Err(Error::NotFound(format!(
372                "memory {id} not found or already deleted"
373            )));
374        }
375        Ok(())
376    }
377
378    async fn hard_delete_memory(&self, id: Uuid) -> Result<()> {
379        let conn = self.conn.lock().await;
380        let affected = conn.execute(
381            "DELETE FROM memories WHERE id = ?",
382            duckdb::params![id.to_string()],
383        )?;
384        if affected == 0 {
385            return Err(Error::NotFound(format!("memory {id} not found")));
386        }
387        // Also clean up ACLs
388        conn.execute(
389            "DELETE FROM acls WHERE memory_id = ?",
390            duckdb::params![id.to_string()],
391        )?;
392        Ok(())
393    }
394
395    async fn list_memories(
396        &self,
397        filter: &MemoryFilter,
398        limit: usize,
399        offset: usize,
400    ) -> Result<Vec<MemoryRecord>> {
401        let conn = self.conn.lock().await;
402        let mut conditions = Vec::new();
403        let mut params: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
404
405        if !filter.include_deleted {
406            conditions.push("deleted_at IS NULL".to_string());
407        }
408
409        if let Some(ref agent_id) = filter.agent_id {
410            conditions.push(format!("agent_id = ${}", params.len() + 1));
411            params.push(Box::new(agent_id.clone()));
412        }
413
414        if let Some(memory_type) = filter.memory_type {
415            conditions.push(format!("memory_type = ${}", params.len() + 1));
416            params.push(Box::new(memory_type.to_string()));
417        }
418
419        if let Some(scope) = filter.scope {
420            conditions.push(format!("scope = ${}", params.len() + 1));
421            params.push(Box::new(scope.to_string()));
422        }
423
424        if let Some(min_importance) = filter.min_importance {
425            conditions.push(format!("importance >= ${}", params.len() + 1));
426            params.push(Box::new(min_importance));
427        }
428
429        if let Some(ref org_id) = filter.org_id {
430            conditions.push(format!("org_id = ${}", params.len() + 1));
431            params.push(Box::new(org_id.clone()));
432        }
433
434        if let Some(ref thread_id) = filter.thread_id {
435            conditions.push(format!("thread_id = ${}", params.len() + 1));
436            params.push(Box::new(thread_id.clone()));
437        }
438
439        let where_clause = if conditions.is_empty() {
440            String::new()
441        } else {
442            format!("WHERE {}", conditions.join(" AND "))
443        };
444
445        let sql = format!(
446            "SELECT id, agent_id, content, memory_type, scope, importance, tags, metadata, embedding, content_hash, prev_hash, source_type, source_id, consolidation_state, access_count, org_id, thread_id, created_at, updated_at, last_accessed_at, expires_at, deleted_at, decay_rate, created_by, version, prev_version_id, quarantined, quarantine_reason, decay_function FROM memories {where_clause} ORDER BY created_at DESC LIMIT {limit} OFFSET {offset}"
447        );
448
449        let mut stmt = conn.prepare(&sql)?;
450        let param_refs: Vec<&dyn duckdb::ToSql> = params.iter().map(|p| p.as_ref()).collect();
451        let rows = stmt.query_map(param_refs.as_slice(), row_to_memory)?;
452
453        let mut results = Vec::new();
454        for row in rows {
455            results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
456        }
457        Ok(results)
458    }
459
460    async fn touch_memory(&self, id: Uuid) -> Result<()> {
461        let conn = self.conn.lock().await;
462        let now = chrono::Utc::now().to_rfc3339();
463        conn.execute(
464            "UPDATE memories SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ?",
465            duckdb::params![now, id.to_string()],
466        )?;
467        Ok(())
468    }
469
470    async fn insert_acl(&self, acl: &Acl) -> Result<()> {
471        let conn = self.conn.lock().await;
472        conn.execute(
473            "INSERT INTO acls (id, memory_id, principal_type, principal_id, permission, granted_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
474            duckdb::params![
475                acl.id.to_string(),
476                acl.memory_id.to_string(),
477                acl.principal_type.to_string(),
478                acl.principal_id,
479                acl.permission.to_string(),
480                acl.granted_by,
481                acl.created_at,
482                acl.expires_at,
483            ],
484        )?;
485        Ok(())
486    }
487
488    async fn check_permission(
489        &self,
490        memory_id: Uuid,
491        principal_id: &str,
492        required: Permission,
493    ) -> Result<bool> {
494        // Do all DuckDB work in one block, then release the lock before delegation check
495        let acl_result = {
496            let conn = self.conn.lock().await;
497
498            // Check if the principal is the owner (agent_id matches)
499            let mut stmt = conn.prepare("SELECT agent_id FROM memories WHERE id = ?")?;
500            let owner_result =
501                stmt.query_row([memory_id.to_string()], |row| row.get::<_, String>(0));
502            match owner_result {
503                Ok(owner) if owner == principal_id => return Ok(true),
504                Err(duckdb::Error::QueryReturnedNoRows) => {
505                    return Err(Error::NotFound(format!("memory {memory_id} not found")));
506                }
507                _ => {}
508            }
509
510            // Check ACLs
511            let now = chrono::Utc::now().to_rfc3339();
512            let mut stmt = conn.prepare(
513                "SELECT permission FROM acls WHERE memory_id = ? AND principal_id = ? AND (expires_at IS NULL OR expires_at > ?)",
514            )?;
515            let rows = stmt.query_map(
516                duckdb::params![memory_id.to_string(), principal_id, now.clone()],
517                |row| row.get::<_, String>(0),
518            )?;
519
520            let mut perms: Vec<String> = Vec::new();
521            for row in rows {
522                perms.push(row.map_err(|e| Error::Storage(e.to_string()))?);
523            }
524
525            // Check public ACLs
526            let mut stmt = conn.prepare(
527                "SELECT permission FROM acls WHERE memory_id = ? AND principal_type = 'public' AND (expires_at IS NULL OR expires_at > ?)",
528            )?;
529            let rows = stmt.query_map(duckdb::params![memory_id.to_string(), now], |row| {
530                row.get::<_, String>(0)
531            })?;
532
533            for row in rows {
534                perms.push(row.map_err(|e| Error::Storage(e.to_string()))?);
535            }
536
537            perms
538        }; // conn lock dropped here
539
540        for perm_str in &acl_result {
541            if let Ok(perm) = perm_str.parse::<Permission>()
542                && perm.satisfies(required)
543            {
544                return Ok(true);
545            }
546        }
547
548        // Check delegations (conn lock is released)
549        if self
550            .check_delegation(principal_id, memory_id, required)
551            .await?
552        {
553            return Ok(true);
554        }
555
556        Ok(false)
557    }
558
559    async fn insert_relation(&self, relation: &Relation) -> Result<()> {
560        let conn = self.conn.lock().await;
561        conn.execute(
562            "INSERT INTO relations (id, source_id, target_id, relation_type, weight, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
563            duckdb::params![
564                relation.id.to_string(),
565                relation.source_id.to_string(),
566                relation.target_id.to_string(),
567                relation.relation_type,
568                relation.weight,
569                serde_json::to_string(&relation.metadata)?,
570                relation.created_at,
571            ],
572        )?;
573        Ok(())
574    }
575
576    async fn get_relations_from(&self, source_id: Uuid) -> Result<Vec<Relation>> {
577        let conn = self.conn.lock().await;
578        let mut stmt = conn.prepare(
579            "SELECT id, source_id, target_id, relation_type, weight, metadata, created_at FROM relations WHERE source_id = ?",
580        )?;
581        let rows = stmt.query_map([source_id.to_string()], row_to_relation)?;
582        let mut results = Vec::new();
583        for row in rows {
584            results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
585        }
586        Ok(results)
587    }
588
589    async fn get_relations_to(&self, target_id: Uuid) -> Result<Vec<Relation>> {
590        let conn = self.conn.lock().await;
591        let mut stmt = conn.prepare(
592            "SELECT id, source_id, target_id, relation_type, weight, metadata, created_at FROM relations WHERE target_id = ?",
593        )?;
594        let rows = stmt.query_map([target_id.to_string()], row_to_relation)?;
595        let mut results = Vec::new();
596        for row in rows {
597            results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
598        }
599        Ok(results)
600    }
601
602    async fn delete_relation(&self, id: Uuid) -> Result<()> {
603        let conn = self.conn.lock().await;
604        let affected = conn.execute(
605            "DELETE FROM relations WHERE id = ?",
606            duckdb::params![id.to_string()],
607        )?;
608        if affected == 0 {
609            return Err(Error::NotFound(format!("relation {id} not found")));
610        }
611        Ok(())
612    }
613
614    async fn get_latest_memory_hash(
615        &self,
616        agent_id: &str,
617        thread_id: Option<&str>,
618    ) -> Result<Option<Vec<u8>>> {
619        let conn = self.conn.lock().await;
620        let (sql, result) = if let Some(tid) = thread_id {
621            let mut stmt = conn.prepare(
622                "SELECT content_hash FROM memories WHERE agent_id = ? AND thread_id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
623            )?;
624            let r = stmt.query_row(duckdb::params![agent_id, tid], |row| {
625                row.get::<_, Vec<u8>>(0)
626            });
627            ((), r)
628        } else {
629            let mut stmt = conn.prepare(
630                "SELECT content_hash FROM memories WHERE agent_id = ? AND thread_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
631            )?;
632            let r = stmt.query_row(duckdb::params![agent_id], |row| row.get::<_, Vec<u8>>(0));
633            ((), r)
634        };
635        let _ = sql;
636        match result {
637            Ok(hash) => Ok(Some(hash)),
638            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
639            Err(e) => Err(Error::Storage(e.to_string())),
640        }
641    }
642
643    async fn get_latest_event_hash(
644        &self,
645        agent_id: &str,
646        thread_id: Option<&str>,
647    ) -> Result<Option<Vec<u8>>> {
648        let conn = self.conn.lock().await;
649        let result = if let Some(tid) = thread_id {
650            let mut stmt = conn.prepare(
651                "SELECT content_hash FROM agent_events WHERE agent_id = ? AND thread_id = ? ORDER BY timestamp DESC LIMIT 1",
652            )?;
653            stmt.query_row(duckdb::params![agent_id, tid], |row| {
654                row.get::<_, Vec<u8>>(0)
655            })
656        } else {
657            let mut stmt = conn.prepare(
658                "SELECT content_hash FROM agent_events WHERE agent_id = ? ORDER BY timestamp DESC LIMIT 1",
659            )?;
660            stmt.query_row(duckdb::params![agent_id], |row| row.get::<_, Vec<u8>>(0))
661        };
662        match result {
663            Ok(hash) => Ok(Some(hash)),
664            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
665            Err(e) => Err(Error::Storage(e.to_string())),
666        }
667    }
668
669    async fn get_sync_watermark(&self, key: &str) -> Result<Option<String>> {
670        let conn = self.conn.lock().await;
671        let mut stmt = conn.prepare("SELECT value FROM sync_metadata WHERE key = ?")?;
672        let result = stmt.query_row(duckdb::params![key], |row| row.get::<_, String>(0));
673        match result {
674            Ok(value) => Ok(Some(value)),
675            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
676            Err(e) => Err(Error::Storage(e.to_string())),
677        }
678    }
679
680    async fn set_sync_watermark(&self, key: &str, value: &str) -> Result<()> {
681        let conn = self.conn.lock().await;
682        let now = chrono::Utc::now().to_rfc3339();
683        // Try update first, then insert
684        let affected = conn.execute(
685            "UPDATE sync_metadata SET value = ?, updated_at = ? WHERE key = ?",
686            duckdb::params![value, now, key],
687        )?;
688        if affected == 0 {
689            conn.execute(
690                "INSERT INTO sync_metadata (key, value, updated_at) VALUES (?, ?, ?)",
691                duckdb::params![key, value, now],
692            )?;
693        }
694        Ok(())
695    }
696
697    async fn list_accessible_memory_ids(&self, agent_id: &str, limit: usize) -> Result<Vec<Uuid>> {
698        let conn = self.conn.lock().await;
699        let now = chrono::Utc::now().to_rfc3339();
700        let mut stmt = conn.prepare(
701            "SELECT id FROM memories WHERE (agent_id = ? OR scope = 'public' OR id IN (SELECT memory_id FROM acls WHERE principal_id = ? AND (expires_at IS NULL OR expires_at > ?))) AND deleted_at IS NULL LIMIT ?",
702        )?;
703        let rows = stmt.query_map(
704            duckdb::params![agent_id, agent_id, now, limit as i64],
705            |row| row.get::<_, String>(0),
706        )?;
707        let mut ids = Vec::new();
708        for row in rows {
709            let id_str = row.map_err(|e| Error::Storage(e.to_string()))?;
710            ids.push(Uuid::parse_str(&id_str).map_err(|e| Error::Storage(e.to_string()))?);
711        }
712        Ok(ids)
713    }
714
715    async fn insert_event(&self, event: &AgentEvent) -> Result<()> {
716        let conn = self.conn.lock().await;
717        let payload_json = serde_json::to_string(&event.payload)?;
718        let embedding_blob = serialize_embedding(&event.embedding);
719        conn.execute(
720            "INSERT INTO agent_events (id, agent_id, thread_id, run_id, parent_event_id, event_type, payload, trace_id, span_id, model, tokens_input, tokens_output, latency_ms, cost_usd, timestamp, logical_clock, content_hash, prev_hash, embedding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
721            duckdb::params![
722                event.id.to_string(),
723                event.agent_id,
724                event.thread_id,
725                event.run_id,
726                event.parent_event_id.map(|id| id.to_string()),
727                event.event_type.to_string(),
728                payload_json,
729                event.trace_id,
730                event.span_id,
731                event.model,
732                event.tokens_input,
733                event.tokens_output,
734                event.latency_ms,
735                event.cost_usd,
736                event.timestamp,
737                event.logical_clock,
738                event.content_hash,
739                event.prev_hash,
740                embedding_blob,
741            ],
742        )?;
743        Ok(())
744    }
745
746    async fn list_events(
747        &self,
748        agent_id: &str,
749        limit: usize,
750        offset: usize,
751    ) -> Result<Vec<AgentEvent>> {
752        let conn = self.conn.lock().await;
753        let mut stmt = conn.prepare(
754            "SELECT id, agent_id, thread_id, run_id, parent_event_id, event_type, payload, trace_id, span_id, model, tokens_input, tokens_output, latency_ms, cost_usd, timestamp, logical_clock, content_hash, prev_hash, embedding FROM agent_events WHERE agent_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
755        )?;
756        let rows = stmt.query_map(
757            duckdb::params![agent_id, limit as i64, offset as i64],
758            row_to_event,
759        )?;
760        let mut results = Vec::new();
761        for row in rows {
762            results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
763        }
764        Ok(results)
765    }
766
767    async fn get_events_by_thread(&self, thread_id: &str, limit: usize) -> Result<Vec<AgentEvent>> {
768        let conn = self.conn.lock().await;
769        let mut stmt = conn.prepare(
770            "SELECT id, agent_id, thread_id, run_id, parent_event_id, event_type, payload, trace_id, span_id, model, tokens_input, tokens_output, latency_ms, cost_usd, timestamp, logical_clock, content_hash, prev_hash, embedding FROM agent_events WHERE thread_id = ? ORDER BY timestamp ASC LIMIT ?",
771        )?;
772        let rows = stmt.query_map(duckdb::params![thread_id, limit as i64], row_to_event)?;
773        let mut results = Vec::new();
774        for row in rows {
775            results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
776        }
777        Ok(results)
778    }
779
780    async fn get_event(&self, id: Uuid) -> Result<Option<AgentEvent>> {
781        let conn = self.conn.lock().await;
782        let mut stmt = conn.prepare(
783            "SELECT id, agent_id, thread_id, run_id, parent_event_id, event_type, payload, trace_id, span_id, model, tokens_input, tokens_output, latency_ms, cost_usd, timestamp, logical_clock, content_hash, prev_hash, embedding FROM agent_events WHERE id = ?",
784        )?;
785        let result = stmt.query_row([id.to_string()], row_to_event);
786        match result {
787            Ok(event) => Ok(Some(event)),
788            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
789            Err(e) => Err(Error::Storage(e.to_string())),
790        }
791    }
792
793    async fn list_child_events(
794        &self,
795        parent_event_id: Uuid,
796        limit: usize,
797    ) -> Result<Vec<AgentEvent>> {
798        let conn = self.conn.lock().await;
799        let mut stmt = conn.prepare(
800            "SELECT id, agent_id, thread_id, run_id, parent_event_id, event_type, payload, trace_id, span_id, model, tokens_input, tokens_output, latency_ms, cost_usd, timestamp, logical_clock, content_hash, prev_hash, embedding FROM agent_events WHERE parent_event_id = ? ORDER BY timestamp ASC LIMIT ?",
801        )?;
802        let rows = stmt.query_map(
803            duckdb::params![parent_event_id.to_string(), limit as i64],
804            row_to_event,
805        )?;
806        let mut results = Vec::new();
807        for row in rows {
808            results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
809        }
810        Ok(results)
811    }
812
813    async fn list_memories_by_agent_ordered(
814        &self,
815        agent_id: &str,
816        thread_id: Option<&str>,
817        limit: usize,
818    ) -> Result<Vec<MemoryRecord>> {
819        let conn = self.conn.lock().await;
820        let (result,) = if let Some(tid) = thread_id {
821            let mut stmt = conn.prepare(
822                "SELECT id, agent_id, content, memory_type, scope, importance, tags, metadata, embedding, content_hash, prev_hash, source_type, source_id, consolidation_state, access_count, org_id, thread_id, created_at, updated_at, last_accessed_at, expires_at, deleted_at, decay_rate, created_by, version, prev_version_id, quarantined, quarantine_reason, decay_function FROM memories WHERE agent_id = ? AND thread_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT ?",
823            )?;
824            let rows =
825                stmt.query_map(duckdb::params![agent_id, tid, limit as i64], row_to_memory)?;
826            let mut results = Vec::new();
827            for row in rows {
828                results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
829            }
830            (results,)
831        } else {
832            let mut stmt = conn.prepare(
833                "SELECT id, agent_id, content, memory_type, scope, importance, tags, metadata, embedding, content_hash, prev_hash, source_type, source_id, consolidation_state, access_count, org_id, thread_id, created_at, updated_at, last_accessed_at, expires_at, deleted_at, decay_rate, created_by, version, prev_version_id, quarantined, quarantine_reason, decay_function FROM memories WHERE agent_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT ?",
834            )?;
835            let rows = stmt.query_map(duckdb::params![agent_id, limit as i64], row_to_memory)?;
836            let mut results = Vec::new();
837            for row in rows {
838                results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
839            }
840            (results,)
841        };
842        Ok(result)
843    }
844
845    async fn list_memories_since(
846        &self,
847        updated_after: &str,
848        limit: usize,
849    ) -> Result<Vec<MemoryRecord>> {
850        let conn = self.conn.lock().await;
851        let mut stmt = conn.prepare(
852            "SELECT id, agent_id, content, memory_type, scope, importance, tags, metadata, embedding, content_hash, prev_hash, source_type, source_id, consolidation_state, access_count, org_id, thread_id, created_at, updated_at, last_accessed_at, expires_at, deleted_at, decay_rate, created_by, version, prev_version_id, quarantined, quarantine_reason, decay_function FROM memories WHERE updated_at > ? ORDER BY updated_at ASC LIMIT ?",
853        )?;
854        let rows = stmt.query_map(duckdb::params![updated_after, limit as i64], row_to_memory)?;
855        let mut results = Vec::new();
856        for row in rows {
857            results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
858        }
859        Ok(results)
860    }
861
862    async fn upsert_memory(&self, record: &MemoryRecord) -> Result<()> {
863        // Try update first; if no rows affected, insert
864        match self.update_memory(record).await {
865            Ok(()) => Ok(()),
866            Err(Error::NotFound(_)) => self.insert_memory(record).await,
867            Err(e) => Err(e),
868        }
869    }
870
871    async fn cleanup_expired(&self) -> Result<usize> {
872        let conn = self.conn.lock().await;
873        let now = chrono::Utc::now().to_rfc3339();
874        let affected = conn.execute(
875            "UPDATE memories SET deleted_at = ? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL",
876            duckdb::params![now.clone(), now],
877        )?;
878        Ok(affected)
879    }
880
881    async fn insert_delegation(&self, d: &Delegation) -> Result<()> {
882        let conn = self.conn.lock().await;
883        let scope_type = d.scope.to_string();
884        let scope_value = match &d.scope {
885            DelegationScope::AllMemories => serde_json::Value::Null,
886            DelegationScope::ByTag(tags) => serde_json::json!(tags),
887            DelegationScope::ByMemoryId(ids) => {
888                serde_json::json!(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
889            }
890        };
891        let scope_value_json = serde_json::to_string(&scope_value)?;
892
893        conn.execute(
894            "INSERT INTO delegations (id, delegator_id, delegate_id, permission, scope_type, scope_value, max_depth, current_depth, parent_delegation_id, created_at, expires_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
895            duckdb::params![
896                d.id.to_string(),
897                d.delegator_id,
898                d.delegate_id,
899                d.permission.to_string(),
900                scope_type,
901                scope_value_json,
902                d.max_depth as i32,
903                d.current_depth as i32,
904                d.parent_delegation_id.map(|id| id.to_string()),
905                d.created_at,
906                d.expires_at,
907                d.revoked_at,
908            ],
909        )?;
910        Ok(())
911    }
912
913    async fn list_delegations_for(&self, delegate_id: &str) -> Result<Vec<Delegation>> {
914        let conn = self.conn.lock().await;
915        let now = chrono::Utc::now().to_rfc3339();
916        let mut stmt = conn.prepare(
917            "SELECT id, delegator_id, delegate_id, permission, scope_type, scope_value, max_depth, current_depth, parent_delegation_id, created_at, expires_at, revoked_at FROM delegations WHERE delegate_id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)",
918        )?;
919        let rows = stmt.query_map(duckdb::params![delegate_id, now], row_to_delegation)?;
920        let mut results = Vec::new();
921        for row in rows {
922            results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
923        }
924        Ok(results)
925    }
926
927    async fn revoke_delegation(&self, id: Uuid) -> Result<()> {
928        let conn = self.conn.lock().await;
929        let now = chrono::Utc::now().to_rfc3339();
930        let affected = conn.execute(
931            "UPDATE delegations SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
932            duckdb::params![now, id.to_string()],
933        )?;
934        if affected == 0 {
935            return Err(Error::NotFound(format!(
936                "delegation {id} not found or already revoked"
937            )));
938        }
939        Ok(())
940    }
941
942    async fn check_delegation(
943        &self,
944        delegate_id: &str,
945        memory_id: Uuid,
946        required: Permission,
947    ) -> Result<bool> {
948        let delegations = self.list_delegations_for(delegate_id).await?;
949        // Get the memory to check scope
950        let memory = match self.get_memory(memory_id).await? {
951            Some(m) => m,
952            None => return Ok(false),
953        };
954
955        for d in &delegations {
956            if !d.permission.satisfies(required) {
957                continue;
958            }
959            match &d.scope {
960                DelegationScope::AllMemories => return Ok(true),
961                DelegationScope::ByMemoryId(ids) => {
962                    if ids.contains(&memory_id) {
963                        return Ok(true);
964                    }
965                }
966                DelegationScope::ByTag(tags) => {
967                    if tags.iter().any(|t| memory.tags.contains(t)) {
968                        return Ok(true);
969                    }
970                }
971            }
972        }
973        Ok(false)
974    }
975
976    async fn insert_or_update_agent_profile(&self, profile: &AgentProfile) -> Result<()> {
977        let conn = self.conn.lock().await;
978        // Try update first, then insert
979        let affected = conn.execute(
980            "UPDATE agent_profiles SET avg_importance = ?, avg_content_length = ?, total_memories = ?, last_updated = ? WHERE agent_id = ?",
981            duckdb::params![
982                profile.avg_importance,
983                profile.avg_content_length,
984                profile.total_memories as i64,
985                profile.last_updated,
986                profile.agent_id,
987            ],
988        )?;
989        if affected == 0 {
990            conn.execute(
991                "INSERT INTO agent_profiles (agent_id, avg_importance, avg_content_length, total_memories, last_updated) VALUES (?, ?, ?, ?, ?)",
992                duckdb::params![
993                    profile.agent_id,
994                    profile.avg_importance,
995                    profile.avg_content_length,
996                    profile.total_memories as i64,
997                    profile.last_updated,
998                ],
999            )?;
1000        }
1001        Ok(())
1002    }
1003
1004    async fn get_agent_profile(&self, agent_id: &str) -> Result<Option<AgentProfile>> {
1005        let conn = self.conn.lock().await;
1006        let mut stmt = conn.prepare(
1007            "SELECT agent_id, avg_importance, avg_content_length, total_memories, last_updated FROM agent_profiles WHERE agent_id = ?",
1008        )?;
1009        let result = stmt.query_row([agent_id], |row| {
1010            Ok(AgentProfile {
1011                agent_id: row.get(0)?,
1012                avg_importance: row.get(1)?,
1013                avg_content_length: row.get(2)?,
1014                total_memories: row.get::<_, i64>(3)? as u64,
1015                last_updated: row.get(4)?,
1016            })
1017        });
1018        match result {
1019            Ok(profile) => Ok(Some(profile)),
1020            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
1021            Err(e) => Err(Error::Storage(e.to_string())),
1022        }
1023    }
1024
1025    async fn insert_or_update_embedding_baseline(
1026        &self,
1027        baseline: &EmbeddingBaseline,
1028    ) -> Result<()> {
1029        let conn = self.conn.lock().await;
1030        let mu_json = serde_json::to_string(&baseline.mu)?;
1031        let cov_json = serde_json::to_string(&baseline.cov_diag)?;
1032        let affected = conn.execute(
1033            "UPDATE embedding_baseline SET mu = ?, cov_diag = ?, n = ?, updated_at = ? WHERE agent_id = ?",
1034            duckdb::params![
1035                mu_json,
1036                cov_json,
1037                baseline.n as i64,
1038                baseline.updated_at,
1039                baseline.agent_id,
1040            ],
1041        )?;
1042        if affected == 0 {
1043            let mu_json = serde_json::to_string(&baseline.mu)?;
1044            let cov_json = serde_json::to_string(&baseline.cov_diag)?;
1045            conn.execute(
1046                "INSERT INTO embedding_baseline (agent_id, mu, cov_diag, n, updated_at) VALUES (?, ?, ?, ?, ?)",
1047                duckdb::params![
1048                    baseline.agent_id,
1049                    mu_json,
1050                    cov_json,
1051                    baseline.n as i64,
1052                    baseline.updated_at,
1053                ],
1054            )?;
1055        }
1056        Ok(())
1057    }
1058
1059    async fn get_embedding_baseline(&self, agent_id: &str) -> Result<Option<EmbeddingBaseline>> {
1060        let conn = self.conn.lock().await;
1061        let mut stmt = conn.prepare(
1062            "SELECT agent_id, mu, cov_diag, n, updated_at FROM embedding_baseline WHERE agent_id = ?",
1063        )?;
1064        let result: duckdb::Result<(String, String, String, i64, String)> =
1065            stmt.query_row([agent_id], |row| {
1066                Ok((
1067                    row.get(0)?,
1068                    row.get(1)?,
1069                    row.get(2)?,
1070                    row.get(3)?,
1071                    row.get(4)?,
1072                ))
1073            });
1074        match result {
1075            Ok((agent_id, mu_json, cov_json, n, updated_at)) => {
1076                let mu: Vec<f32> = serde_json::from_str(&mu_json)?;
1077                let cov_diag: Vec<f32> = serde_json::from_str(&cov_json)?;
1078                Ok(Some(EmbeddingBaseline {
1079                    agent_id,
1080                    mu,
1081                    cov_diag,
1082                    n: n as u64,
1083                    updated_at,
1084                }))
1085            }
1086            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
1087            Err(e) => Err(Error::Storage(e.to_string())),
1088        }
1089    }
1090
1091    async fn insert_checkpoint(&self, cp: &Checkpoint) -> Result<()> {
1092        let conn = self.conn.lock().await;
1093        let state_snapshot_json = serde_json::to_string(&cp.state_snapshot)?;
1094        let state_diff_json = cp
1095            .state_diff
1096            .as_ref()
1097            .map(serde_json::to_string)
1098            .transpose()?;
1099        let memory_refs_json = serde_json::to_string(
1100            &cp.memory_refs
1101                .iter()
1102                .map(|id| id.to_string())
1103                .collect::<Vec<_>>(),
1104        )?;
1105        let metadata_json = serde_json::to_string(&cp.metadata)?;
1106
1107        conn.execute(
1108            "INSERT INTO checkpoints (id, thread_id, agent_id, parent_id, branch_name, state_snapshot, state_diff, memory_refs, event_cursor, label, created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1109            duckdb::params![
1110                cp.id.to_string(),
1111                cp.thread_id,
1112                cp.agent_id,
1113                cp.parent_id.map(|id| id.to_string()),
1114                cp.branch_name,
1115                state_snapshot_json,
1116                state_diff_json,
1117                memory_refs_json,
1118                cp.event_cursor.map(|id| id.to_string()),
1119                cp.label,
1120                cp.created_at,
1121                metadata_json,
1122            ],
1123        )?;
1124        Ok(())
1125    }
1126
1127    async fn get_checkpoint(&self, id: Uuid) -> Result<Option<Checkpoint>> {
1128        let conn = self.conn.lock().await;
1129        let mut stmt = conn.prepare(
1130            "SELECT id, thread_id, agent_id, parent_id, branch_name, state_snapshot, state_diff, memory_refs, event_cursor, label, created_at, metadata FROM checkpoints WHERE id = ?",
1131        )?;
1132        let result = stmt.query_row([id.to_string()], row_to_checkpoint);
1133        match result {
1134            Ok(cp) => Ok(Some(cp)),
1135            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
1136            Err(e) => Err(Error::Storage(e.to_string())),
1137        }
1138    }
1139
1140    async fn list_checkpoints(
1141        &self,
1142        thread_id: &str,
1143        branch: Option<&str>,
1144        limit: usize,
1145    ) -> Result<Vec<Checkpoint>> {
1146        let conn = self.conn.lock().await;
1147        let (sql, rows_result) = if let Some(branch_name) = branch {
1148            let mut stmt = conn.prepare(
1149                "SELECT id, thread_id, agent_id, parent_id, branch_name, state_snapshot, state_diff, memory_refs, event_cursor, label, created_at, metadata FROM checkpoints WHERE thread_id = ? AND branch_name = ? ORDER BY created_at DESC LIMIT ?",
1150            )?;
1151            let rows = stmt.query_map(
1152                duckdb::params![thread_id, branch_name, limit as i64],
1153                row_to_checkpoint,
1154            )?;
1155            let mut results = Vec::new();
1156            for row in rows {
1157                results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
1158            }
1159            ((), Ok(results))
1160        } else {
1161            let mut stmt = conn.prepare(
1162                "SELECT id, thread_id, agent_id, parent_id, branch_name, state_snapshot, state_diff, memory_refs, event_cursor, label, created_at, metadata FROM checkpoints WHERE thread_id = ? ORDER BY created_at DESC LIMIT ?",
1163            )?;
1164            let rows =
1165                stmt.query_map(duckdb::params![thread_id, limit as i64], row_to_checkpoint)?;
1166            let mut results = Vec::new();
1167            for row in rows {
1168                results.push(row.map_err(|e| Error::Storage(e.to_string()))?);
1169            }
1170            ((), Ok(results))
1171        };
1172        let _ = sql;
1173        rows_result
1174    }
1175
1176    async fn get_latest_checkpoint(
1177        &self,
1178        thread_id: &str,
1179        branch: &str,
1180    ) -> Result<Option<Checkpoint>> {
1181        let conn = self.conn.lock().await;
1182        let mut stmt = conn.prepare(
1183            "SELECT id, thread_id, agent_id, parent_id, branch_name, state_snapshot, state_diff, memory_refs, event_cursor, label, created_at, metadata FROM checkpoints WHERE thread_id = ? AND branch_name = ? ORDER BY created_at DESC LIMIT 1",
1184        )?;
1185        let result = stmt.query_row(duckdb::params![thread_id, branch], row_to_checkpoint);
1186        match result {
1187            Ok(cp) => Ok(Some(cp)),
1188            Err(duckdb::Error::QueryReturnedNoRows) => Ok(None),
1189            Err(e) => Err(Error::Storage(e.to_string())),
1190        }
1191    }
1192}
1193
1194fn row_to_event(row: &duckdb::Row<'_>) -> duckdb::Result<AgentEvent> {
1195    let id_str: String = row.get(0)?;
1196    let parent_id_str: Option<String> = row.get(4)?;
1197    let payload_json: Option<String> = row.get(6)?;
1198    let event_type_str: String = row.get(5)?;
1199    let content_hash: Vec<u8> = row.get(16)?;
1200    let prev_hash: Option<Vec<u8>> = row.get(17)?;
1201    let embedding_blob: Option<Vec<u8>> = row.get(18).unwrap_or(None);
1202
1203    Ok(AgentEvent {
1204        id: Uuid::parse_str(&id_str).map_err(|e| {
1205            duckdb::Error::FromSqlConversionFailure(0, duckdb::types::Type::Text, Box::new(e))
1206        })?,
1207        agent_id: row.get(1)?,
1208        thread_id: row.get(2)?,
1209        run_id: row.get(3)?,
1210        parent_event_id: parent_id_str.and_then(|s| Uuid::parse_str(&s).ok()),
1211        event_type: event_type_str.parse().map_err(|e: Error| {
1212            duckdb::Error::FromSqlConversionFailure(
1213                5,
1214                duckdb::types::Type::Text,
1215                e.to_string().into(),
1216            )
1217        })?,
1218        payload: payload_json
1219            .and_then(|s| serde_json::from_str(&s).ok())
1220            .unwrap_or(serde_json::Value::Null),
1221        trace_id: row.get(7)?,
1222        span_id: row.get(8)?,
1223        model: row.get(9)?,
1224        tokens_input: row.get(10)?,
1225        tokens_output: row.get(11)?,
1226        latency_ms: row.get(12)?,
1227        cost_usd: row.get(13)?,
1228        timestamp: row.get(14)?,
1229        logical_clock: row.get(15)?,
1230        content_hash,
1231        prev_hash,
1232        embedding: deserialize_embedding(embedding_blob),
1233    })
1234}
1235
1236fn row_to_checkpoint(row: &duckdb::Row<'_>) -> duckdb::Result<Checkpoint> {
1237    let id_str: String = row.get(0)?;
1238    let parent_id_str: Option<String> = row.get(3)?;
1239    let state_snapshot_json: Option<String> = row.get(5)?;
1240    let state_diff_json: Option<String> = row.get(6)?;
1241    let memory_refs_json: Option<String> = row.get(7)?;
1242    let event_cursor_str: Option<String> = row.get(8)?;
1243    let metadata_json: Option<String> = row.get(11)?;
1244
1245    Ok(Checkpoint {
1246        id: Uuid::parse_str(&id_str).map_err(|e| {
1247            duckdb::Error::FromSqlConversionFailure(0, duckdb::types::Type::Text, Box::new(e))
1248        })?,
1249        thread_id: row.get(1)?,
1250        agent_id: row.get(2)?,
1251        parent_id: parent_id_str.and_then(|s| Uuid::parse_str(&s).ok()),
1252        branch_name: row.get(4)?,
1253        state_snapshot: state_snapshot_json
1254            .and_then(|s| serde_json::from_str(&s).ok())
1255            .unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
1256        state_diff: state_diff_json.and_then(|s| serde_json::from_str(&s).ok()),
1257        memory_refs: memory_refs_json
1258            .and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
1259            .map(|v| {
1260                v.into_iter()
1261                    .filter_map(|s| Uuid::parse_str(&s).ok())
1262                    .collect()
1263            })
1264            .unwrap_or_default(),
1265        event_cursor: event_cursor_str.and_then(|s| Uuid::parse_str(&s).ok()),
1266        label: row.get(9)?,
1267        created_at: row.get(10)?,
1268        metadata: metadata_json
1269            .and_then(|s| serde_json::from_str(&s).ok())
1270            .unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
1271    })
1272}
1273
1274fn collect_memory_ids(rows: impl Iterator<Item = duckdb::Result<String>>) -> Result<Vec<Uuid>> {
1275    let mut out = Vec::new();
1276    for r in rows {
1277        let s = r.map_err(|e| Error::Storage(e.to_string()))?;
1278        out.push(Uuid::parse_str(&s).map_err(|e| Error::Storage(e.to_string()))?);
1279    }
1280    Ok(out)
1281}
1282
1283fn row_to_write_provenance(row: &duckdb::Row<'_>) -> duckdb::Result<WriteProvenance> {
1284    let id_str: String = row.get(0)?;
1285    let memory_id_str: String = row.get(1)?;
1286    let principal: String = row.get(2)?;
1287    let capability_id_str: Option<String> = row.get(3)?;
1288    let session_id: Option<String> = row.get(4)?;
1289    let op_str: String = row.get(5)?;
1290    let authored_at_str: String = row.get(6)?;
1291    // `flags` was added after the table's first release; an older row stores NULL
1292    // (read as None → empty flag set). Column order: ..., authored_at(6),
1293    // flags(7), prev_hash(8), content_hash(9).
1294    let flags_str: Option<String> = row.get(7)?;
1295    let prev_hash: Option<Vec<u8>> = row.get(8)?;
1296    let content_hash: Vec<u8> = row.get(9)?;
1297    let flags = flags_from_storage(flags_str.as_deref().unwrap_or(""));
1298
1299    let uuid_err = |col: usize, e: uuid::Error| {
1300        duckdb::Error::FromSqlConversionFailure(col, duckdb::types::Type::Text, Box::new(e))
1301    };
1302    let op = match op_str.as_str() {
1303        "remember" => WriteOp::Remember,
1304        "share" => WriteOp::Share,
1305        other => {
1306            return Err(duckdb::Error::FromSqlConversionFailure(
1307                5,
1308                duckdb::types::Type::Text,
1309                format!("unknown write op `{other}`").into(),
1310            ));
1311        }
1312    };
1313    let authored_at = chrono::DateTime::parse_from_rfc3339(&authored_at_str)
1314        .map(|dt| dt.with_timezone(&chrono::Utc))
1315        .map_err(|e| {
1316            duckdb::Error::FromSqlConversionFailure(6, duckdb::types::Type::Text, Box::new(e))
1317        })?;
1318    let capability_id = match capability_id_str {
1319        Some(s) => Some(Uuid::parse_str(&s).map_err(|e| uuid_err(3, e))?),
1320        None => None,
1321    };
1322    Ok(WriteProvenance {
1323        id: Uuid::parse_str(&id_str).map_err(|e| uuid_err(0, e))?,
1324        memory_id: Uuid::parse_str(&memory_id_str).map_err(|e| uuid_err(1, e))?,
1325        principal,
1326        capability_id,
1327        session_id,
1328        op,
1329        authored_at,
1330        flags,
1331        prev_hash,
1332        content_hash,
1333    })
1334}
1335
1336fn row_to_delegation(row: &duckdb::Row<'_>) -> duckdb::Result<Delegation> {
1337    let id_str: String = row.get(0)?;
1338    let scope_type: String = row.get(4)?;
1339    let scope_value_json: Option<String> = row.get(5)?;
1340    let parent_id_str: Option<String> = row.get(8)?;
1341
1342    let scope = match scope_type.as_str() {
1343        "by_tag" => {
1344            let tags: Vec<String> = scope_value_json
1345                .and_then(|s| serde_json::from_str(&s).ok())
1346                .unwrap_or_default();
1347            DelegationScope::ByTag(tags)
1348        }
1349        "by_memory_id" => {
1350            let ids: Vec<String> = scope_value_json
1351                .and_then(|s| serde_json::from_str(&s).ok())
1352                .unwrap_or_default();
1353            let uuids = ids
1354                .into_iter()
1355                .filter_map(|s| Uuid::parse_str(&s).ok())
1356                .collect();
1357            DelegationScope::ByMemoryId(uuids)
1358        }
1359        _ => DelegationScope::AllMemories,
1360    };
1361
1362    let permission_str: String = row.get(3)?;
1363
1364    Ok(Delegation {
1365        id: Uuid::parse_str(&id_str).map_err(|e| {
1366            duckdb::Error::FromSqlConversionFailure(0, duckdb::types::Type::Text, Box::new(e))
1367        })?,
1368        delegator_id: row.get(1)?,
1369        delegate_id: row.get(2)?,
1370        permission: permission_str.parse().map_err(|e: Error| {
1371            duckdb::Error::FromSqlConversionFailure(
1372                3,
1373                duckdb::types::Type::Text,
1374                e.to_string().into(),
1375            )
1376        })?,
1377        scope,
1378        max_depth: row.get::<_, i32>(6)? as u32,
1379        current_depth: row.get::<_, i32>(7)? as u32,
1380        parent_delegation_id: parent_id_str.and_then(|s| Uuid::parse_str(&s).ok()),
1381        created_at: row.get(9)?,
1382        expires_at: row.get(10)?,
1383        revoked_at: row.get(11)?,
1384    })
1385}
1386
1387fn row_to_relation(row: &duckdb::Row<'_>) -> duckdb::Result<Relation> {
1388    let id_str: String = row.get(0)?;
1389    let source_str: String = row.get(1)?;
1390    let target_str: String = row.get(2)?;
1391    let metadata_json: Option<String> = row.get(5)?;
1392
1393    Ok(Relation {
1394        id: Uuid::parse_str(&id_str).map_err(|e| {
1395            duckdb::Error::FromSqlConversionFailure(0, duckdb::types::Type::Text, Box::new(e))
1396        })?,
1397        source_id: Uuid::parse_str(&source_str).map_err(|e| {
1398            duckdb::Error::FromSqlConversionFailure(1, duckdb::types::Type::Text, Box::new(e))
1399        })?,
1400        target_id: Uuid::parse_str(&target_str).map_err(|e| {
1401            duckdb::Error::FromSqlConversionFailure(2, duckdb::types::Type::Text, Box::new(e))
1402        })?,
1403        relation_type: row.get(3)?,
1404        weight: row.get(4)?,
1405        metadata: metadata_json
1406            .and_then(|s| serde_json::from_str(&s).ok())
1407            .unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
1408        created_at: row.get(6)?,
1409    })
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::*;
1415    use crate::hash::compute_content_hash;
1416    use crate::model::acl::PrincipalType;
1417    use crate::model::checkpoint::Checkpoint;
1418    use crate::model::event::{AgentEvent, EventType};
1419    use crate::model::memory::{ConsolidationState, MemoryType, Scope, SourceType};
1420
1421    fn make_record(agent_id: &str) -> MemoryRecord {
1422        let now = chrono::Utc::now().to_rfc3339();
1423        let content = "test memory content";
1424        MemoryRecord {
1425            id: Uuid::now_v7(),
1426            agent_id: agent_id.to_string(),
1427            content: content.to_string(),
1428            memory_type: MemoryType::Semantic,
1429            scope: Scope::Private,
1430            importance: 0.7,
1431            tags: vec!["test".to_string()],
1432            metadata: serde_json::json!({"key": "value"}),
1433            embedding: Some(vec![0.1, 0.2, 0.3]),
1434            content_hash: compute_content_hash(content, agent_id, &now),
1435            prev_hash: None,
1436            source_type: SourceType::Agent,
1437            source_id: None,
1438            consolidation_state: ConsolidationState::Raw,
1439            access_count: 0,
1440            org_id: None,
1441            thread_id: None,
1442            created_at: now.clone(),
1443            updated_at: now,
1444            last_accessed_at: None,
1445            expires_at: None,
1446            deleted_at: None,
1447            decay_rate: None,
1448            created_by: None,
1449            version: 1,
1450            prev_version_id: None,
1451            quarantined: false,
1452            quarantine_reason: None,
1453            decay_function: None,
1454        }
1455    }
1456
1457    #[tokio::test]
1458    async fn test_insert_and_get() {
1459        let storage = DuckDbStorage::open_in_memory().unwrap();
1460        let record = make_record("agent-1");
1461        storage.insert_memory(&record).await.unwrap();
1462
1463        let fetched = storage.get_memory(record.id).await.unwrap().unwrap();
1464        assert_eq!(fetched.id, record.id);
1465        assert_eq!(fetched.content, record.content);
1466        assert_eq!(fetched.agent_id, record.agent_id);
1467        assert_eq!(fetched.memory_type, record.memory_type);
1468        assert_eq!(fetched.tags, record.tags);
1469        assert_eq!(fetched.embedding, record.embedding);
1470    }
1471
1472    #[tokio::test]
1473    async fn test_get_nonexistent() {
1474        let storage = DuckDbStorage::open_in_memory().unwrap();
1475        let result = storage.get_memory(Uuid::now_v7()).await.unwrap();
1476        assert!(result.is_none());
1477    }
1478
1479    #[tokio::test]
1480    async fn test_soft_delete() {
1481        let storage = DuckDbStorage::open_in_memory().unwrap();
1482        let record = make_record("agent-1");
1483        storage.insert_memory(&record).await.unwrap();
1484
1485        storage.soft_delete_memory(record.id).await.unwrap();
1486
1487        // Should still exist in DB but with deleted_at set
1488        let fetched = storage.get_memory(record.id).await.unwrap().unwrap();
1489        assert!(fetched.deleted_at.is_some());
1490
1491        // Should not appear in list by default
1492        let filter = MemoryFilter::default();
1493        let list = storage.list_memories(&filter, 100, 0).await.unwrap();
1494        assert!(list.is_empty());
1495
1496        // Should appear with include_deleted
1497        let filter_with_deleted = MemoryFilter {
1498            include_deleted: true,
1499            ..Default::default()
1500        };
1501        let list = storage
1502            .list_memories(&filter_with_deleted, 100, 0)
1503            .await
1504            .unwrap();
1505        assert_eq!(list.len(), 1);
1506    }
1507
1508    #[tokio::test]
1509    async fn test_hard_delete() {
1510        let storage = DuckDbStorage::open_in_memory().unwrap();
1511        let record = make_record("agent-1");
1512        storage.insert_memory(&record).await.unwrap();
1513
1514        storage.hard_delete_memory(record.id).await.unwrap();
1515
1516        let result = storage.get_memory(record.id).await.unwrap();
1517        assert!(result.is_none());
1518    }
1519
1520    #[tokio::test]
1521    async fn test_list_with_filters() {
1522        let storage = DuckDbStorage::open_in_memory().unwrap();
1523
1524        let mut r1 = make_record("agent-1");
1525        r1.memory_type = MemoryType::Episodic;
1526        storage.insert_memory(&r1).await.unwrap();
1527
1528        let mut r2 = make_record("agent-1");
1529        r2.memory_type = MemoryType::Semantic;
1530        storage.insert_memory(&r2).await.unwrap();
1531
1532        let mut r3 = make_record("agent-2");
1533        r3.memory_type = MemoryType::Semantic;
1534        storage.insert_memory(&r3).await.unwrap();
1535
1536        // Filter by agent
1537        let filter = MemoryFilter {
1538            agent_id: Some("agent-1".to_string()),
1539            ..Default::default()
1540        };
1541        let list = storage.list_memories(&filter, 100, 0).await.unwrap();
1542        assert_eq!(list.len(), 2);
1543
1544        // Filter by type
1545        let filter = MemoryFilter {
1546            memory_type: Some(MemoryType::Semantic),
1547            ..Default::default()
1548        };
1549        let list = storage.list_memories(&filter, 100, 0).await.unwrap();
1550        assert_eq!(list.len(), 2);
1551
1552        // Filter by agent + type
1553        let filter = MemoryFilter {
1554            agent_id: Some("agent-1".to_string()),
1555            memory_type: Some(MemoryType::Episodic),
1556            ..Default::default()
1557        };
1558        let list = storage.list_memories(&filter, 100, 0).await.unwrap();
1559        assert_eq!(list.len(), 1);
1560    }
1561
1562    #[tokio::test]
1563    async fn test_touch_memory() {
1564        let storage = DuckDbStorage::open_in_memory().unwrap();
1565        let record = make_record("agent-1");
1566        storage.insert_memory(&record).await.unwrap();
1567
1568        storage.touch_memory(record.id).await.unwrap();
1569        storage.touch_memory(record.id).await.unwrap();
1570
1571        let fetched = storage.get_memory(record.id).await.unwrap().unwrap();
1572        assert_eq!(fetched.access_count, 2);
1573        assert!(fetched.last_accessed_at.is_some());
1574    }
1575
1576    #[tokio::test]
1577    async fn test_acl_and_permission_check() {
1578        let storage = DuckDbStorage::open_in_memory().unwrap();
1579        let record = make_record("agent-1");
1580        storage.insert_memory(&record).await.unwrap();
1581
1582        // Owner always has permission
1583        assert!(
1584            storage
1585                .check_permission(record.id, "agent-1", Permission::Admin)
1586                .await
1587                .unwrap()
1588        );
1589
1590        // Non-owner has no permission by default
1591        assert!(
1592            !storage
1593                .check_permission(record.id, "agent-2", Permission::Read)
1594                .await
1595                .unwrap()
1596        );
1597
1598        // Grant read to agent-2
1599        let acl = Acl {
1600            id: Uuid::now_v7(),
1601            memory_id: record.id,
1602            principal_type: PrincipalType::Agent,
1603            principal_id: "agent-2".to_string(),
1604            permission: Permission::Read,
1605            granted_by: "agent-1".to_string(),
1606            created_at: chrono::Utc::now().to_rfc3339(),
1607            expires_at: None,
1608        };
1609        storage.insert_acl(&acl).await.unwrap();
1610
1611        // Now agent-2 can read
1612        assert!(
1613            storage
1614                .check_permission(record.id, "agent-2", Permission::Read)
1615                .await
1616                .unwrap()
1617        );
1618        // But not write
1619        assert!(
1620            !storage
1621                .check_permission(record.id, "agent-2", Permission::Write)
1622                .await
1623                .unwrap()
1624        );
1625    }
1626
1627    #[tokio::test]
1628    async fn test_event_insert_and_list() {
1629        let storage = DuckDbStorage::open_in_memory().unwrap();
1630        let now = chrono::Utc::now().to_rfc3339();
1631        let event = AgentEvent {
1632            id: Uuid::now_v7(),
1633            agent_id: "agent-1".to_string(),
1634            thread_id: Some("thread-1".to_string()),
1635            run_id: None,
1636            parent_event_id: None,
1637            event_type: EventType::MemoryWrite,
1638            payload: serde_json::json!({"memory_id": "abc"}),
1639            trace_id: None,
1640            span_id: None,
1641            model: None,
1642            tokens_input: None,
1643            tokens_output: None,
1644            latency_ms: None,
1645            cost_usd: None,
1646            timestamp: now.clone(),
1647            logical_clock: 1,
1648            content_hash: vec![1, 2, 3],
1649            prev_hash: None,
1650            embedding: None,
1651        };
1652
1653        storage.insert_event(&event).await.unwrap();
1654
1655        let events = storage.list_events("agent-1", 10, 0).await.unwrap();
1656        assert_eq!(events.len(), 1);
1657        assert_eq!(events[0].id, event.id);
1658        assert_eq!(events[0].event_type, EventType::MemoryWrite);
1659        assert_eq!(events[0].agent_id, "agent-1");
1660
1661        // Get single event
1662        let fetched = storage.get_event(event.id).await.unwrap().unwrap();
1663        assert_eq!(fetched.id, event.id);
1664        assert_eq!(fetched.content_hash, vec![1, 2, 3]);
1665    }
1666
1667    #[tokio::test]
1668    async fn test_events_by_thread() {
1669        let storage = DuckDbStorage::open_in_memory().unwrap();
1670        let now = chrono::Utc::now().to_rfc3339();
1671
1672        for i in 0..3 {
1673            let event = AgentEvent {
1674                id: Uuid::now_v7(),
1675                agent_id: "agent-1".to_string(),
1676                thread_id: Some("thread-A".to_string()),
1677                run_id: None,
1678                parent_event_id: None,
1679                event_type: EventType::MemoryWrite,
1680                payload: serde_json::json!({"i": i}),
1681                trace_id: None,
1682                span_id: None,
1683                model: None,
1684                tokens_input: None,
1685                tokens_output: None,
1686                latency_ms: None,
1687                cost_usd: None,
1688                timestamp: now.clone(),
1689                logical_clock: i,
1690                content_hash: vec![i as u8],
1691                prev_hash: None,
1692                embedding: None,
1693            };
1694            storage.insert_event(&event).await.unwrap();
1695        }
1696
1697        // Different thread
1698        let event = AgentEvent {
1699            id: Uuid::now_v7(),
1700            agent_id: "agent-1".to_string(),
1701            thread_id: Some("thread-B".to_string()),
1702            run_id: None,
1703            parent_event_id: None,
1704            event_type: EventType::MemoryRead,
1705            payload: serde_json::json!({}),
1706            trace_id: None,
1707            span_id: None,
1708            model: None,
1709            tokens_input: None,
1710            tokens_output: None,
1711            latency_ms: None,
1712            cost_usd: None,
1713            timestamp: now.clone(),
1714            logical_clock: 0,
1715            content_hash: vec![99],
1716            prev_hash: None,
1717            embedding: None,
1718        };
1719        storage.insert_event(&event).await.unwrap();
1720
1721        let thread_a = storage.get_events_by_thread("thread-A", 10).await.unwrap();
1722        assert_eq!(thread_a.len(), 3);
1723
1724        let thread_b = storage.get_events_by_thread("thread-B", 10).await.unwrap();
1725        assert_eq!(thread_b.len(), 1);
1726        assert_eq!(thread_b[0].event_type, EventType::MemoryRead);
1727    }
1728
1729    #[tokio::test]
1730    async fn test_checkpoint_insert_and_get() {
1731        let storage = DuckDbStorage::open_in_memory().unwrap();
1732        let mem_id = Uuid::now_v7();
1733        let cp = Checkpoint {
1734            id: Uuid::now_v7(),
1735            thread_id: "thread-1".to_string(),
1736            agent_id: "agent-1".to_string(),
1737            parent_id: None,
1738            branch_name: "main".to_string(),
1739            state_snapshot: serde_json::json!({"step": 1}),
1740            state_diff: None,
1741            memory_refs: vec![mem_id],
1742            event_cursor: None,
1743            label: Some("initial".to_string()),
1744            created_at: chrono::Utc::now().to_rfc3339(),
1745            metadata: serde_json::json!({}),
1746        };
1747
1748        storage.insert_checkpoint(&cp).await.unwrap();
1749
1750        let fetched = storage.get_checkpoint(cp.id).await.unwrap().unwrap();
1751        assert_eq!(fetched.id, cp.id);
1752        assert_eq!(fetched.thread_id, "thread-1");
1753        assert_eq!(fetched.branch_name, "main");
1754        assert_eq!(fetched.memory_refs, vec![mem_id]);
1755        assert_eq!(fetched.label, Some("initial".to_string()));
1756    }
1757
1758    #[tokio::test]
1759    async fn test_checkpoint_list_and_latest() {
1760        let storage = DuckDbStorage::open_in_memory().unwrap();
1761
1762        let cp1 = Checkpoint {
1763            id: Uuid::now_v7(),
1764            thread_id: "thread-1".to_string(),
1765            agent_id: "agent-1".to_string(),
1766            parent_id: None,
1767            branch_name: "main".to_string(),
1768            state_snapshot: serde_json::json!({"step": 1}),
1769            state_diff: None,
1770            memory_refs: vec![],
1771            event_cursor: None,
1772            label: Some("first".to_string()),
1773            created_at: "2025-01-01T00:00:00Z".to_string(),
1774            metadata: serde_json::json!({}),
1775        };
1776        storage.insert_checkpoint(&cp1).await.unwrap();
1777
1778        let cp2 = Checkpoint {
1779            id: Uuid::now_v7(),
1780            thread_id: "thread-1".to_string(),
1781            agent_id: "agent-1".to_string(),
1782            parent_id: Some(cp1.id),
1783            branch_name: "main".to_string(),
1784            state_snapshot: serde_json::json!({"step": 2}),
1785            state_diff: Some(serde_json::json!({"step": [1, 2]})),
1786            memory_refs: vec![],
1787            event_cursor: None,
1788            label: Some("second".to_string()),
1789            created_at: "2025-01-02T00:00:00Z".to_string(),
1790            metadata: serde_json::json!({}),
1791        };
1792        storage.insert_checkpoint(&cp2).await.unwrap();
1793
1794        let cp3 = Checkpoint {
1795            id: Uuid::now_v7(),
1796            thread_id: "thread-1".to_string(),
1797            agent_id: "agent-1".to_string(),
1798            parent_id: Some(cp1.id),
1799            branch_name: "experiment".to_string(),
1800            state_snapshot: serde_json::json!({"step": "alt"}),
1801            state_diff: None,
1802            memory_refs: vec![],
1803            event_cursor: None,
1804            label: None,
1805            created_at: "2025-01-03T00:00:00Z".to_string(),
1806            metadata: serde_json::json!({}),
1807        };
1808        storage.insert_checkpoint(&cp3).await.unwrap();
1809
1810        // List all for thread
1811        let all = storage
1812            .list_checkpoints("thread-1", None, 10)
1813            .await
1814            .unwrap();
1815        assert_eq!(all.len(), 3);
1816
1817        // List by branch
1818        let main_cps = storage
1819            .list_checkpoints("thread-1", Some("main"), 10)
1820            .await
1821            .unwrap();
1822        assert_eq!(main_cps.len(), 2);
1823
1824        let exp_cps = storage
1825            .list_checkpoints("thread-1", Some("experiment"), 10)
1826            .await
1827            .unwrap();
1828        assert_eq!(exp_cps.len(), 1);
1829
1830        // Latest on main
1831        let latest = storage
1832            .get_latest_checkpoint("thread-1", "main")
1833            .await
1834            .unwrap()
1835            .unwrap();
1836        assert_eq!(latest.id, cp2.id);
1837
1838        // Latest on experiment
1839        let latest_exp = storage
1840            .get_latest_checkpoint("thread-1", "experiment")
1841            .await
1842            .unwrap()
1843            .unwrap();
1844        assert_eq!(latest_exp.id, cp3.id);
1845
1846        // No checkpoints for nonexistent branch
1847        let none = storage
1848            .get_latest_checkpoint("thread-1", "nonexistent")
1849            .await
1850            .unwrap();
1851        assert!(none.is_none());
1852    }
1853}