Skip to main content

semantic_memory/
knowledge.rs

1//! Fact CRUD with FTS5 synchronization.
2//!
3//! Every fact operation that touches `facts_fts` is transactional.
4//! See SPEC.md §8.3 for the insert/update/delete procedures.
5
6use crate::db::{bytes_to_embedding, with_transaction};
7use crate::error::MemoryError;
8use crate::types::Fact;
9use rusqlite::{params, Connection};
10
11/// Insert a fact and its FTS entry in a transaction.
12///
13/// Called after embedding is already computed.
14pub fn insert_fact_with_fts(
15    conn: &Connection,
16    fact_id: &str,
17    namespace: &str,
18    content: &str,
19    embedding_bytes: &[u8],
20    source: Option<&str>,
21    metadata: Option<&serde_json::Value>,
22) -> Result<(), MemoryError> {
23    insert_fact_with_fts_q8(conn, fact_id, namespace, content, embedding_bytes, None, source, metadata)
24}
25
26/// Insert a fact with both f32 and quantized embeddings.
27#[allow(clippy::too_many_arguments)]
28pub fn insert_fact_with_fts_q8(
29    conn: &Connection,
30    fact_id: &str,
31    namespace: &str,
32    content: &str,
33    embedding_bytes: &[u8],
34    q8_bytes: Option<&[u8]>,
35    source: Option<&str>,
36    metadata: Option<&serde_json::Value>,
37) -> Result<(), MemoryError> {
38    let metadata_str = metadata.map(|m| m.to_string());
39    with_transaction(conn, |tx| {
40        // 1. Insert into facts table
41        tx.execute(
42            "INSERT INTO facts (id, namespace, content, source, embedding, embedding_q8, metadata) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
43            params![fact_id, namespace, content, source, embedding_bytes, q8_bytes, metadata_str],
44        )?;
45
46        // 2. Insert into rowid bridge
47        tx.execute(
48            "INSERT INTO facts_rowid_map (fact_id) VALUES (?1)",
49            params![fact_id],
50        )?;
51        let fts_rowid = tx.last_insert_rowid();
52
53        // 3. Insert into FTS
54        tx.execute(
55            "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
56            params![fts_rowid, content],
57        )?;
58
59        Ok(())
60    })
61}
62
63/// Delete a fact and its FTS entry in a transaction.
64///
65/// Must read content BEFORE deleting from the main table (contentless FTS
66/// requires the original content for delete).
67pub fn delete_fact_with_fts(conn: &Connection, fact_id: &str) -> Result<(), MemoryError> {
68    with_transaction(conn, |tx| {
69        // 1. Get FTS rowid from bridge
70        let fts_rowid: i64 = tx
71            .query_row(
72                "SELECT rowid FROM facts_rowid_map WHERE fact_id = ?1",
73                params![fact_id],
74                |row| row.get(0),
75            )
76            .map_err(|_| MemoryError::FactNotFound(fact_id.to_string()))?;
77
78        // 2. Get content (needed for FTS delete)
79        let content: String = tx
80            .query_row(
81                "SELECT content FROM facts WHERE id = ?1",
82                params![fact_id],
83                |row| row.get(0),
84            )
85            .map_err(|_| MemoryError::FactNotFound(fact_id.to_string()))?;
86
87        // 3. Delete from FTS (contentless FTS delete syntax)
88        tx.execute(
89            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
90            params![fts_rowid, content],
91        )?;
92
93        // 4. Delete from bridge
94        tx.execute(
95            "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
96            params![fact_id],
97        )?;
98
99        // 5. Delete from facts
100        tx.execute("DELETE FROM facts WHERE id = ?1", params![fact_id])?;
101
102        Ok(())
103    })
104}
105
106/// Update a fact's content and embedding, with FTS sync.
107///
108/// Called after new embedding is already computed.
109pub fn update_fact_with_fts(
110    conn: &Connection,
111    fact_id: &str,
112    new_content: &str,
113    new_embedding_bytes: &[u8],
114) -> Result<(), MemoryError> {
115    with_transaction(conn, |tx| {
116        // 1. Get old FTS rowid and content
117        let (fts_rowid, old_content): (i64, String) = tx
118            .query_row(
119                "SELECT fm.rowid, f.content FROM facts f
120                 JOIN facts_rowid_map fm ON fm.fact_id = f.id
121                 WHERE f.id = ?1",
122                params![fact_id],
123                |row| Ok((row.get(0)?, row.get(1)?)),
124            )
125            .map_err(|_| MemoryError::FactNotFound(fact_id.to_string()))?;
126
127        // 2. Delete old FTS entry
128        tx.execute(
129            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
130            params![fts_rowid, old_content],
131        )?;
132
133        // 3. Update facts table
134        tx.execute(
135            "UPDATE facts SET content = ?1, embedding = ?2, updated_at = datetime('now') WHERE id = ?3",
136            params![new_content, new_embedding_bytes, fact_id],
137        )?;
138
139        // 4. Insert new FTS entry (reuse same rowid)
140        tx.execute(
141            "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
142            params![fts_rowid, new_content],
143        )?;
144
145        Ok(())
146    })
147}
148
149/// Delete all facts in a namespace atomically. Returns count of deleted facts.
150///
151/// All deletions happen in a single transaction so the operation is atomic —
152/// either all facts are deleted or none are (no partial namespace deletion on crash).
153pub fn delete_namespace(conn: &Connection, namespace: &str) -> Result<usize, MemoryError> {
154    with_transaction(conn, |tx| {
155        // 1. Get all fact IDs, FTS rowids, and content in the namespace
156        let mut stmt = tx.prepare(
157            "SELECT f.id, fm.rowid, f.content
158             FROM facts f
159             JOIN facts_rowid_map fm ON fm.fact_id = f.id
160             WHERE f.namespace = ?1",
161        )?;
162        let facts: Vec<(String, i64, String)> = stmt
163            .query_map(params![namespace], |row| {
164                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
165            })?
166            .collect::<Result<Vec<_>, _>>()?;
167
168        let count = facts.len();
169
170        // 2. Delete FTS entries and bridge rows for each fact
171        for (fact_id, fts_rowid, content) in &facts {
172            tx.execute(
173                "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
174                params![fts_rowid, content],
175            )?;
176            tx.execute(
177                "DELETE FROM facts_rowid_map WHERE fact_id = ?1",
178                params![fact_id],
179            )?;
180        }
181
182        // 3. Delete all facts in the namespace
183        tx.execute(
184            "DELETE FROM facts WHERE namespace = ?1",
185            params![namespace],
186        )?;
187
188        Ok(count)
189    })
190}
191
192/// Get a fact by ID.
193pub fn get_fact(conn: &Connection, fact_id: &str) -> Result<Option<Fact>, MemoryError> {
194    let result = conn.query_row(
195        "SELECT id, namespace, content, source, created_at, updated_at, metadata
196         FROM facts WHERE id = ?1",
197        params![fact_id],
198        |row| {
199            let metadata_str: Option<String> = row.get(6)?;
200            Ok(Fact {
201                id: row.get(0)?,
202                namespace: row.get(1)?,
203                content: row.get(2)?,
204                source: row.get(3)?,
205                created_at: row.get(4)?,
206                updated_at: row.get(5)?,
207                metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
208            })
209        },
210    );
211
212    match result {
213        Ok(fact) => Ok(Some(fact)),
214        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
215        Err(e) => Err(MemoryError::Database(e)),
216    }
217}
218
219/// Get a fact's raw embedding bytes.
220pub fn get_fact_embedding(
221    conn: &Connection,
222    fact_id: &str,
223) -> Result<Option<Vec<f32>>, MemoryError> {
224    let result: Result<Option<Vec<u8>>, _> = conn.query_row(
225        "SELECT embedding FROM facts WHERE id = ?1",
226        params![fact_id],
227        |row| row.get(0),
228    );
229
230    match result {
231        Ok(Some(bytes)) => Ok(Some(bytes_to_embedding(&bytes)?)),
232        Ok(None) => Ok(None),
233        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
234        Err(e) => Err(MemoryError::Database(e)),
235    }
236}
237
238/// List facts in a namespace with pagination.
239pub fn list_facts(
240    conn: &Connection,
241    namespace: &str,
242    limit: usize,
243    offset: usize,
244) -> Result<Vec<Fact>, MemoryError> {
245    let mut stmt = conn.prepare(
246        "SELECT id, namespace, content, source, created_at, updated_at, metadata
247         FROM facts
248         WHERE namespace = ?1
249         ORDER BY updated_at DESC
250         LIMIT ?2 OFFSET ?3",
251    )?;
252
253    let facts = stmt
254        .query_map(params![namespace, limit as i64, offset as i64], |row| {
255            let metadata_str: Option<String> = row.get(6)?;
256            Ok(Fact {
257                id: row.get(0)?,
258                namespace: row.get(1)?,
259                content: row.get(2)?,
260                source: row.get(3)?,
261                created_at: row.get(4)?,
262                updated_at: row.get(5)?,
263                metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
264            })
265        })?
266        .collect::<Result<Vec<_>, _>>()?;
267
268    Ok(facts)
269}