1use rusqlite::{params, Connection};
12
13use crate::errors::AppError;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum PendingStatus {
20 Validated,
22 EmbeddingInProgress,
24 EmbeddingDone,
26 Committed,
28 Abandoned,
30 Failed,
32}
33
34impl PendingStatus {
35 pub fn as_str(&self) -> &'static str {
37 match self {
38 Self::Validated => "validated",
39 Self::EmbeddingInProgress => "embedding_in_progress",
40 Self::EmbeddingDone => "embedding_done",
41 Self::Committed => "committed",
42 Self::Abandoned => "abandoned",
43 Self::Failed => "failed",
44 }
45 }
46}
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub struct PendingMemory {
51 pub pending_id: i64,
53 pub name: String,
55 pub namespace: String,
57 pub memory_type: String,
59 pub description: Option<String>,
61 pub body: Vec<u8>,
63 pub body_hash: String,
65 pub entities_json: Option<String>,
67 pub relationships_json: Option<String>,
69 pub status: PendingStatus,
71 pub embedding: Option<Vec<u8>>,
73 pub embedding_dim: Option<i32>,
75 pub attempt_count: i32,
77 pub last_error: Option<String>,
79 pub created_at: i64,
81 pub updated_at: i64,
83}
84
85#[allow(clippy::too_many_arguments)]
89pub fn insert_validated(
90 conn: &Connection,
91 name: &str,
92 namespace: &str,
93 memory_type: &str,
94 description: Option<&str>,
95 body: &[u8],
96 body_hash: &str,
97 entities_json: Option<&str>,
98 relationships_json: Option<&str>,
99) -> Result<i64, AppError> {
100 conn.execute(
101 "INSERT INTO pending_memories
102 (name, namespace, memory_type, description, body, body_hash,
103 entities_json, relationships_json, status, attempt_count)
104 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'validated', 0)",
105 params![
106 name,
107 namespace,
108 memory_type,
109 description,
110 body,
111 body_hash,
112 entities_json,
113 relationships_json,
114 ],
115 )?;
116 Ok(conn.last_insert_rowid())
117}
118
119pub fn update_to_embedding_in_progress(conn: &Connection, pending_id: i64) -> Result<(), AppError> {
121 conn.execute(
122 "UPDATE pending_memories
123 SET status = 'embedding_in_progress',
124 attempt_count = attempt_count + 1,
125 updated_at = unixepoch()
126 WHERE pending_id = ?1",
127 params![pending_id],
128 )?;
129 Ok(())
130}
131
132pub fn update_to_embedding_done(
134 conn: &Connection,
135 pending_id: i64,
136 embedding: &[u8],
137 dim: i32,
138) -> Result<(), AppError> {
139 conn.execute(
140 "UPDATE pending_memories
141 SET status = 'embedding_done',
142 embedding = ?1,
143 embedding_dim = ?2,
144 updated_at = unixepoch()
145 WHERE pending_id = ?3",
146 params![embedding, dim, pending_id],
147 )?;
148 Ok(())
149}
150
151pub fn mark_committed(conn: &Connection, pending_id: i64) -> Result<(), AppError> {
153 conn.execute(
154 "UPDATE pending_memories
155 SET status = 'committed',
156 updated_at = unixepoch()
157 WHERE pending_id = ?1",
158 params![pending_id],
159 )?;
160 Ok(())
161}
162
163pub fn mark_failed(conn: &Connection, pending_id: i64, error: &str) -> Result<(), AppError> {
165 conn.execute(
166 "UPDATE pending_memories
167 SET status = 'failed',
168 last_error = ?1,
169 updated_at = unixepoch()
170 WHERE pending_id = ?2",
171 params![error, pending_id],
172 )?;
173 Ok(())
174}
175
176pub fn list_by_status(
178 conn: &Connection,
179 status: PendingStatus,
180 limit: usize,
181) -> Result<Vec<PendingMemory>, AppError> {
182 let mut stmt = conn.prepare(
183 "SELECT pending_id, name, namespace, memory_type, description, body,
184 body_hash, entities_json, relationships_json, status,
185 embedding, embedding_dim, attempt_count, last_error,
186 created_at, updated_at
187 FROM pending_memories
188 WHERE status = ?1
189 ORDER BY updated_at ASC
190 LIMIT ?2",
191 )?;
192 let rows = stmt.query_map(params![status.as_str(), limit as i64], |row| {
193 Ok(PendingMemory {
194 pending_id: row.get(0)?,
195 name: row.get(1)?,
196 namespace: row.get(2)?,
197 memory_type: row.get(3)?,
198 description: row.get(4)?,
199 body: row.get(5)?,
200 body_hash: row.get(6)?,
201 entities_json: row.get(7)?,
202 relationships_json: row.get(8)?,
203 status: parse_status(&row.get::<_, String>(9)?).map_err(|e| -> rusqlite::Error {
204 rusqlite::Error::FromSqlConversionFailure(
205 9,
206 rusqlite::types::Type::Text,
207 Box::new(std::io::Error::other(e.to_string())),
208 )
209 })?,
210 embedding: row.get(10)?,
211 embedding_dim: row.get(11)?,
212 attempt_count: row.get(12)?,
213 last_error: row.get(13)?,
214 created_at: row.get(14)?,
215 updated_at: row.get(15)?,
216 })
217 })?;
218 let mut pending = Vec::new();
219 for row in rows {
220 pending.push(row?);
221 }
222 Ok(pending)
223}
224
225pub fn find_by_id(conn: &Connection, pending_id: i64) -> Result<Option<PendingMemory>, AppError> {
227 let mut stmt = conn.prepare(
228 "SELECT pending_id, name, namespace, memory_type, description, body,
229 body_hash, entities_json, relationships_json, status,
230 embedding, embedding_dim, attempt_count, last_error,
231 created_at, updated_at
232 FROM pending_memories
233 WHERE pending_id = ?1",
234 )?;
235 let mut rows = stmt.query(params![pending_id])?;
236 if let Some(row) = rows.next()? {
237 Ok(Some(PendingMemory {
238 pending_id: row.get(0)?,
239 name: row.get(1)?,
240 namespace: row.get(2)?,
241 memory_type: row.get(3)?,
242 description: row.get(4)?,
243 body: row.get(5)?,
244 body_hash: row.get(6)?,
245 entities_json: row.get(7)?,
246 relationships_json: row.get(8)?,
247 status: parse_status(row.get::<_, String>(9)?.as_str())?,
248 embedding: row.get(10)?,
249 embedding_dim: row.get(11)?,
250 attempt_count: row.get(12)?,
251 last_error: row.get(13)?,
252 created_at: row.get(14)?,
253 updated_at: row.get(15)?,
254 }))
255 } else {
256 Ok(None)
257 }
258}
259
260pub fn cleanup_older_than(conn: &Connection, older_than_secs: i64) -> Result<usize, AppError> {
263 let cutoff = chrono::Utc::now().timestamp() - older_than_secs;
264 let count = conn.execute(
265 "DELETE FROM pending_memories
266 WHERE status IN ('embedding_in_progress', 'validated', 'failed')
267 AND updated_at < ?1",
268 params![cutoff],
269 )?;
270 Ok(count)
271}
272
273fn parse_status(s: &str) -> Result<PendingStatus, AppError> {
274 match s {
275 "validated" => Ok(PendingStatus::Validated),
276 "embedding_in_progress" => Ok(PendingStatus::EmbeddingInProgress),
277 "embedding_done" => Ok(PendingStatus::EmbeddingDone),
278 "committed" => Ok(PendingStatus::Committed),
279 "abandoned" => Ok(PendingStatus::Abandoned),
280 "failed" => Ok(PendingStatus::Failed),
281 other => Err(AppError::Validation(crate::i18n::validation::unknown_pending_memories_status(other))),
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use rusqlite::Connection;
289
290 fn fresh_db() -> Connection {
291 let mut conn = Connection::open_in_memory().expect("in-memory db");
292 conn.execute_batch("PRAGMA foreign_keys = ON;")
293 .expect("pragma");
294 crate::migrations::runner()
295 .run(&mut conn)
296 .expect("migrations apply");
297 conn
298 }
299
300 #[test]
301 fn insert_validated_returns_pending_id() {
302 let conn = fresh_db();
303 let id = insert_validated(
304 &conn,
305 "test-pending",
306 "global",
307 "note",
308 Some("desc"),
309 b"body bytes",
310 "blake3-hash-here",
311 None,
312 None,
313 )
314 .expect("insert");
315 assert!(id > 0);
316 }
317
318 #[test]
319 fn status_transition_validated_to_committed() {
320 let conn = fresh_db();
321 let id =
322 insert_validated(&conn, "x", "global", "note", None, b"b", "h", None, None).unwrap();
323 update_to_embedding_in_progress(&conn, id).unwrap();
324 let p = find_by_id(&conn, id).unwrap().unwrap();
325 assert_eq!(p.status, PendingStatus::EmbeddingInProgress);
326 assert_eq!(p.attempt_count, 1);
327
328 let fake_emb: Vec<u8> = vec![0u8; 64 * 4]; update_to_embedding_done(&conn, id, &fake_emb, 64).unwrap();
331 let p = find_by_id(&conn, id).unwrap().unwrap();
332 assert_eq!(p.status, PendingStatus::EmbeddingDone);
333 assert_eq!(p.embedding_dim, Some(64));
334
335 mark_committed(&conn, id).unwrap();
336 let p = find_by_id(&conn, id).unwrap().unwrap();
337 assert_eq!(p.status, PendingStatus::Committed);
338 }
339
340 #[test]
341 fn list_by_status_filters_correctly() {
342 let conn = fresh_db();
343 let id1 =
344 insert_validated(&conn, "a", "global", "note", None, b"b", "h", None, None).unwrap();
345 let _id2 =
346 insert_validated(&conn, "b", "global", "note", None, b"b", "h", None, None).unwrap();
347 mark_committed(&conn, id1).unwrap();
348 let validated = list_by_status(&conn, PendingStatus::Validated, 10).unwrap();
349 assert_eq!(validated.len(), 1);
350 assert_eq!(validated[0].name, "b");
351 }
352
353 #[test]
354 fn cleanup_older_than_removes_stale() {
355 let conn = fresh_db();
356 let _id = insert_validated(
357 &conn, "stale", "global", "note", None, b"b", "h", None, None,
358 )
359 .unwrap();
360 let removed = cleanup_older_than(&conn, -3600).unwrap();
362 assert_eq!(removed, 1);
363 }
364
365 #[test]
366 fn mark_failed_records_error() {
367 let conn = fresh_db();
368 let id =
369 insert_validated(&conn, "f", "global", "note", None, b"b", "h", None, None).unwrap();
370 mark_failed(&conn, id, "codex exited with OOM").unwrap();
371 let p = find_by_id(&conn, id).unwrap().unwrap();
372 assert_eq!(p.status, PendingStatus::Failed);
373 assert_eq!(p.last_error.as_deref(), Some("codex exited with OOM"));
374 }
375}