1use crate::index::TextIndex;
2use crate::{schema, text, types::*, Error, Result};
3use fs4::fs_std::FileExt;
4use parking_lot::{Mutex, RwLock};
5use rusqlite::{params, params_from_iter, types::Value as SqlValue, Connection, OptionalExtension, Transaction, TransactionBehavior};
6use serde::de::DeserializeOwned;
7use serde_json::Value;
8use std::{collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fs::{File, OpenOptions}, path::{Path, PathBuf}, sync::{atomic::{AtomicU64, Ordering}, Arc}};
9
10pub(crate) struct Writer { pub conn: Connection, _file_lock: File }
12
13pub(crate) struct Readers { pub idle: Vec<Connection> }
16
17pub(crate) struct VectorCache {
22 generation: AtomicU64,
24 bumps: Mutex<HashMap<String, u64>>,
26 entries: Mutex<HashMap<(String, String, String), Option<Arc<crate::embeddings::Partition>>>>,
27}
28
29impl VectorCache {
30 fn new() -> Self {
31 Self { generation: AtomicU64::new(0), bumps: Mutex::new(HashMap::new()), entries: Mutex::new(HashMap::new()) }
32 }
33 pub fn epoch_of(&self, namespace: &str) -> u64 {
35 self.generation.load(Ordering::SeqCst) + self.bumps.lock().get(namespace).copied().unwrap_or(0)
36 }
37 pub fn invalidate(&self) {
40 self.generation.fetch_add(1, Ordering::SeqCst);
41 self.entries.lock().clear();
42 }
43 pub fn invalidate_namespaces(&self, namespaces: &HashSet<String>) {
45 {
46 let mut bumps = self.bumps.lock();
47 for namespace in namespaces { *bumps.entry(namespace.clone()).or_insert(0) += 1; }
48 }
49 self.entries.lock().retain(|(_, namespace, _), _| !namespaces.contains(namespace));
50 }
51}
52
53thread_local! {
54 static TOUCHED_NAMESPACES: std::cell::RefCell<Option<HashSet<String>>> = const { std::cell::RefCell::new(None) };
58}
59
60pub(crate) fn touch_namespace(namespace: &str) {
63 TOUCHED_NAMESPACES.with(|slot| {
64 if let Some(touched) = slot.borrow_mut().as_mut() { touched.insert(text::normalized_tag(namespace)); }
65 });
66}
67
68pub(crate) fn touch_record_namespace(conn: &Connection, record_id: i64) -> Result<()> {
70 if let Some(namespace) = namespace_of(conn, record_id)? { touch_namespace(&namespace); }
71 Ok(())
72}
73
74pub(crate) fn namespace_of(conn: &Connection, record_id: i64) -> Result<Option<String>> {
76 Ok(conn.query_row("SELECT s.text FROM records r JOIN strings s ON s.id=r.namespace_id WHERE r.id=?1",
77 [record_id], |r| r.get(0)).optional()?)
78}
79
80struct TouchLog(Option<HashSet<String>>);
82
83impl TouchLog {
84 fn install() -> Self {
85 Self(TOUCHED_NAMESPACES.with(|slot| slot.borrow_mut().replace(HashSet::new())))
86 }
87 fn take(&self) -> HashSet<String> {
89 TOUCHED_NAMESPACES.with(|slot| slot.borrow_mut().take()).unwrap_or_default()
90 }
91}
92
93impl Drop for TouchLog {
94 fn drop(&mut self) {
95 let previous = self.0.take();
96 TOUCHED_NAMESPACES.with(|slot| *slot.borrow_mut() = previous);
97 }
98}
99
100pub(crate) struct Engine {
101 pub writer: Mutex<Option<Writer>>,
102 pub readers: Mutex<Option<Readers>>,
103 pub vector_writer: Mutex<Option<Writer>>,
106 pub vector_readers: Mutex<Option<Readers>>,
107 pub index: RwLock<Option<Arc<TextIndex>>>,
111 pub vectors: VectorCache,
112 pub embedders: crate::embeddings::EmbedderRegistry,
114 pub rerankers: crate::search::RerankerRegistry,
115 pub events: crate::events::EventRegistry,
117 pub degraded: Mutex<Vec<Degrade>>,
119 pub root: PathBuf,
120}
121
122fn open_reader(root: &Path) -> Result<Connection> {
124 let conn = Connection::open(root.join("store.sqlite3"))?;
125 conn.execute_batch("PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?;
126 conn.execute("ATTACH DATABASE ?1 AS vectors", [root.join("vectors.sqlite3").to_string_lossy().to_string()])?;
128 Ok(conn)
129}
130
131fn open_vector_writer(root: &Path) -> Result<Connection> {
134 let conn = Connection::open(root.join("vectors.sqlite3"))?;
135 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;")?;
136 conn.execute_batch(include_str!("vectors_schema.sql"))?;
137 Ok(conn)
138}
139fn open_vector_reader(root: &Path) -> Result<Connection> {
140 let conn = Connection::open(root.join("vectors.sqlite3"))?;
141 conn.execute_batch("PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL;")?;
142 Ok(conn)
143}
144
145#[derive(Clone)]
147pub struct KnowledgeBase { pub(crate) engine: Arc<Engine> }
148
149pub(crate) struct ReadGuard<'a> { engine: &'a Engine, conn: Option<Connection> }
151
152impl ReadGuard<'_> {
153 pub fn conn(&self) -> &Connection { self.conn.as_ref().expect("read connection lives until drop") }
154}
155
156impl Drop for ReadGuard<'_> {
157 fn drop(&mut self) {
158 let Some(conn) = self.conn.take() else { return };
159 if let Some(readers) = self.engine.readers.lock().as_mut() { readers.idle.push(conn); }
161 }
162}
163
164impl KnowledgeBase {
165 pub fn open(directory: impl AsRef<Path>) -> Result<Self> {
166 std::fs::create_dir_all(directory.as_ref())?;
167 let root = std::fs::canonicalize(directory.as_ref())?;
168 let file_lock = OpenOptions::new().create(true).truncate(false).read(true).write(true).open(root.join("writer.lock"))?;
169 if !file_lock.try_lock_exclusive()? { return Err(Error::Locked(root.display().to_string())); }
170 let mut write_conn = Connection::open(root.join("store.sqlite3"))?;
171 schema::initialize(&mut write_conn)?;
172 let vector_writer = open_vector_writer(&root)?;
174 write_conn.execute("ATTACH DATABASE ?1 AS vectors", [root.join("vectors.sqlite3").to_string_lossy().to_string()])?;
176 let index = Arc::new(TextIndex::open(&root)?);
177 recover_marks(&write_conn, &index)?;
180 let reader = open_reader(&root)?;
182 let vector_reader = open_vector_reader(&root)?;
183 let engine = Arc::new(Engine {
184 writer: Mutex::new(Some(Writer { conn: write_conn, _file_lock: file_lock })),
185 readers: Mutex::new(Some(Readers { idle: vec![reader] })),
186 vector_writer: Mutex::new(Some(Writer { conn: vector_writer, _file_lock: OpenOptions::new().create(true).truncate(false).read(true).write(true).open(root.join("vector_writer.lock"))? })),
187 vector_readers: Mutex::new(Some(Readers { idle: vec![vector_reader] })),
188 index: RwLock::new(Some(index)), vectors: VectorCache::new(),
189 embedders: crate::embeddings::EmbedderRegistry::new(),
190 rerankers: crate::search::RerankerRegistry::new(),
191 events: crate::events::EventRegistry::default(),
192 degraded: Mutex::new(Vec::new()), root,
193 });
194 Ok(Self { engine })
195 }
196
197 pub fn directory(&self) -> &Path { &self.engine.root }
198
199 pub(crate) fn index(&self) -> Result<Arc<TextIndex>> {
201 self.engine.index.read().clone().ok_or(Error::Closed)
202 }
203
204 pub(crate) fn index_documents(&self, docs: &[crate::index::IndexDocument]) -> Result<()> {
207 if docs.is_empty() { return Ok(()); }
208 self.index()?.stage(docs)?;
209 Ok(())
210 }
211
212 pub fn close(&self) -> Result<()> {
213 let mut guard = self.engine.writer.lock();
214 let result = match guard.as_ref() {
215 Some(writer) => self.index()?.sync(&writer.conn).and_then(|()| clear_writing_marks(&writer.conn)),
217 None => Ok(()),
218 };
219 *guard = None;
220 *self.engine.vector_writer.lock() = None;
221 *self.engine.vector_readers.lock() = None;
222 *self.engine.index.write() = None;
224 *self.engine.readers.lock() = None;
225 result
226 }
227
228 pub(crate) fn read(&self) -> Result<ReadGuard<'_>> {
230 let conn = {
231 let mut readers = self.engine.readers.lock();
232 match readers.as_mut() {
233 Some(readers) => match readers.idle.pop() {
234 Some(conn) => conn,
235 None => open_reader(&self.engine.root)?,
236 },
237 None => return Err(Error::Closed),
238 }
239 };
240 Ok(ReadGuard { engine: &self.engine, conn: Some(conn) })
241 }
242
243 pub(crate) fn partition(&self, space: &crate::embeddings::EmbeddingSpace,
247 namespace: &str, scope: &str) -> Result<Option<Arc<crate::embeddings::Partition>>> {
248 let key = (space.id.clone(), namespace.to_string(), scope.to_string());
249 let epoch = self.engine.vectors.epoch_of(namespace);
250 let cached = self.engine.vectors.entries.lock().get(&key).cloned();
251 if let Some(partition) = cached { return Ok(partition); }
252 let loaded = {
253 let conn = {
254 let mut readers = self.engine.vector_readers.lock();
255 match readers.as_mut() {
256 Some(readers) => readers.idle.pop().unwrap_or_else(|| open_vector_reader(&self.engine.root).unwrap_or_else(|_| unreachable!())),
257 None => return Err(Error::Closed),
258 }
259 };
260 let result = crate::embeddings::Partition::load(&conn, space, namespace, scope)?;
261 if let Some(readers) = self.engine.vector_readers.lock().as_mut() { readers.idle.push(conn); }
262 result.map(Arc::new)
263 };
264 {
268 let mut entries = self.engine.vectors.entries.lock();
269 if self.engine.vectors.epoch_of(namespace) == epoch { entries.insert(key, loaded.clone()); }
270 }
271 Ok(loaded)
272 }
273
274 pub(crate) fn mutate<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<(T, Vec<crate::index::IndexDocument>)>) -> Result<WriteReceipt<T>> {
279 let (value, documents, revision, touched, rows_changed) = {
280 let mut guard = self.engine.writer.lock();
281 let writer = guard.as_mut().ok_or(Error::Closed)?;
282 let changed_before = writer.conn.total_changes();
283 let log = TouchLog::install();
284 let tx = writer.conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
285 let (value, documents) = f(&tx)?;
286 let revision = current_revision(&tx)?;
287 tx.commit()?;
288 let touched = log.take();
289 drop(log);
290 (value, documents, revision, touched, writer.conn.total_changes() > changed_before)
291 };
292 if !documents.is_empty() { self.index()?.stage(&documents)?; }
293 if rows_changed {
294 if touched.is_empty() { self.engine.vectors.invalidate(); }
296 else { self.engine.vectors.invalidate_namespaces(&touched); }
297 }
298 Ok(WriteReceipt { value, revision })
299 }
300
301 pub(crate) fn mutate_meta<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<T>) -> Result<WriteReceipt<T>> {
304 let mut guard = self.engine.writer.lock();
305 let writer = guard.as_mut().ok_or(Error::Closed)?;
306 let tx = writer.conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
307 let value = f(&tx)?;
308 let revision = current_revision(&tx)?;
309 tx.commit()?;
310 Ok(WriteReceipt { value, revision })
311 }
312
313 pub fn memories(&self) -> crate::memory::MemoryStore { crate::memory::MemoryStore(self.clone()) }
314 pub fn graph(&self) -> crate::graph::GraphStore { crate::graph::GraphStore(self.clone()) }
315 pub fn notes(&self) -> crate::notes::NoteStore { crate::notes::NoteStore(self.clone()) }
316 pub fn embeddings(&self) -> crate::embeddings::EmbeddingStore { crate::embeddings::EmbeddingStore(self.clone()) }
317
318 pub(crate) fn write<T>(&self, f: impl FnOnce(&Writer) -> Result<T>) -> Result<T> {
321 let mut guard = self.engine.writer.lock();
322 let value = f(guard.as_mut().ok_or(Error::Closed)?)?;
323 self.engine.vectors.invalidate();
324 Ok(value)
325 }
326
327 pub(crate) fn with_writer_lock<T>(&self, f: impl FnOnce(&mut Writer) -> Result<T>) -> Result<T> {
331 let mut guard = self.engine.writer.lock();
332 let log = TouchLog::install();
333 let outcome = f(guard.as_mut().ok_or(Error::Closed)?);
334 let touched = log.take();
335 drop(log);
336 match outcome {
337 Ok(value) => {
338 if touched.is_empty() { self.engine.vectors.invalidate(); }
339 else { self.engine.vectors.invalidate_namespaces(&touched); }
340 Ok(value)
341 }
342 Err(error) => { self.engine.vectors.invalidate(); Err(error) }
343 }
344 }
345
346 pub(crate) fn note_degrade(&self, degrade: Degrade) {
348 let mut observed = self.engine.degraded.lock();
349 if !observed.contains(°rade) {
350 observed.push(degrade);
351 if observed.len() > 8 { observed.remove(0); }
352 }
353 }
354
355 pub(crate) fn delete_flow(&self, ids: &[i64], filter: &ReadFilter, kind: RecordKind,
361 children: impl Fn(&Connection, i64) -> Result<Vec<i64>>) -> Result<usize> {
362 let mut hits = Vec::new();
364 {
365 let state = self.read()?;
366 let conn = state.conn();
367 for id in ids {
368 let hit: Option<i64> = conn.query_row("SELECT id FROM records WHERE id=?1 AND kind=?2",
369 params![id, kind.code()], |r| r.get(0)).optional()?;
370 if hit.is_some() && matches_filter(conn, &RecordKey { id: *id }, filter)? { hits.push(*id); }
371 }
372 }
373 if hits.is_empty() { return Ok(0); }
374 let mut removed = 0usize;
375 let marked_ids = self.with_writer_lock(|writer| {
376 let mut marked: Vec<(i64, i64)> = Vec::new();
378 let tx = writer.conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
379 for id in &hits {
380 for child in children(&tx, *id)? { marked.push((child, RecordKind::Chunk.code())); }
381 marked.push((*id, kind.code()));
382 }
383 marked.sort_by(|a, b| b.1.cmp(&a.1));
385 let marked_ids: Vec<i64> = marked.iter().map(|(id, _)| *id).collect();
386 mark_records(&tx, &marked_ids, MARK_DELETING)?;
387 tx.commit()?;
388 let tx = writer.conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
391 for id in &marked_ids {
392 if delete_record(&tx, &RecordKey { id: *id })? { removed += 1; }
393 }
394 tx.commit()?;
395 Ok(marked_ids)
396 })?;
397 self.index()?.stage_deletions(&marked_ids)?;
399 Ok(removed)
400 }
401
402 pub fn reconcile_index(&self) -> Result<HealthReport> {
407 {
408 let mut guard = self.engine.writer.lock();
409 let writer = guard.as_mut().ok_or(Error::Closed)?;
410 self.index()?.reconcile(&writer.conn)?;
411 }
412 self.health()
413 }
414
415 pub fn update_index(&self) -> Result<HealthReport> {
420 {
421 let mut guard = self.engine.writer.lock();
422 let writer = guard.as_mut().ok_or(Error::Closed)?;
423 self.index()?.sync(&writer.conn)?;
424 clear_writing_marks(&writer.conn)?;
425 }
426 self.health()
427 }
428
429 pub fn register_event_sink<F: Fn(&crate::events::LogEvent) + Send + Sync + 'static>(&self, sink: F) {
433 self.engine.events.set(Arc::new(sink));
434 }
435
436 pub fn unregister_event_sink(&self) -> bool { self.engine.events.clear() }
438
439 pub fn event_sink_registered(&self) -> bool { self.engine.events.is_registered() }
441
442 pub fn health(&self) -> Result<HealthReport> {
443 let state = self.read()?;
444 let conn = state.conn();
445 let mut counts = BTreeMap::new();
446 let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM records GROUP BY kind")?;
447 for row in stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))? {
448 let (code, count) = row?;
449 let name = RecordKind::from_code(code).map(|k| k.as_str().to_string()).unwrap_or_else(|| code.to_string());
450 counts.insert(name, count as usize);
451 }
452 let record_count = counts.values().sum();
453 let mut foreign = conn.prepare("PRAGMA foreign_key_check")?;
454 let mut foreign_key_errors = 0;
455 let mut rows = foreign.query([])?;
456 while rows.next()?.is_some() { foreign_key_errors += 1; }
457 Ok(HealthReport {
458 schema_version: schema::SCHEMA_VERSION,
459 revision: current_revision(conn)?,
460 indexed_revision: meta(conn, "indexed_revision")?,
461 index_document_count: self.index()?.document_count(),
462 record_count,
463 sqlite_integrity: conn.query_row("PRAGMA quick_check", [], |r| r.get(0))?,
464 foreign_key_errors, counts,
465 embedder_spaces: self.engine.embedders.space_ids(),
466 reranker_registered: self.engine.rerankers.is_registered(),
467 last_degraded: self.engine.degraded.lock().clone(),
468 })
469 }
470
471 pub fn backup(&self, target: impl AsRef<Path>) -> Result<()> {
473 let target = target.as_ref();
474 let state = self.read()?;
475 let reservation = OpenOptions::new().write(true).create_new(true).open(target)?;
476 drop(reservation);
477 if let Err(err) = state.conn().backup(rusqlite::MAIN_DB, target, None) {
478 let _ = std::fs::remove_file(target);
479 return Err(err.into());
480 }
481 let vectors_target = target.with_file_name(format!("{}.vectors", target.file_name().unwrap().to_string_lossy()));
483 if let Err(err) = state.conn().backup("vectors", &vectors_target, None) {
484 let _ = std::fs::remove_file(&vectors_target);
485 return Err(err.into());
486 }
487 Ok(())
488 }
489
490 pub fn restore(snapshot: impl AsRef<Path>, directory: impl AsRef<Path>) -> Result<Self> {
492 let snapshot = snapshot.as_ref();
493 let source = Connection::open_with_flags(snapshot, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
494 let app: i64 = source.pragma_query_value(None, "application_id", |r| r.get(0))?;
495 let version: i64 = source.pragma_query_value(None, "user_version", |r| r.get(0))?;
496 if app != schema::APPLICATION_ID { return Err(Error::Validation("snapshot is not a p-memory database".into())); }
497 if version != schema::SCHEMA_VERSION { return Err(Error::SchemaVersion { found: version, supported: schema::SCHEMA_VERSION }); }
498 std::fs::create_dir(directory.as_ref())?;
499 source.backup(rusqlite::MAIN_DB, directory.as_ref().join("store.sqlite3"), None)?;
500 let vectors_snapshot = snapshot.with_file_name(format!("{}.vectors", snapshot.file_name().unwrap().to_string_lossy()));
502 if vectors_snapshot.exists() {
503 let vectors_source = Connection::open_with_flags(&vectors_snapshot, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
504 vectors_source.backup(rusqlite::MAIN_DB, directory.as_ref().join("vectors.sqlite3"), None)?;
505 }
506 Self::open(directory)
507 }
508}
509
510pub(crate) fn now_us() -> i64 { chrono::Utc::now().timestamp_micros() }
511pub(crate) fn meta(conn: &Connection, key: &str) -> Result<i64> {
512 Ok(conn.query_row("SELECT value FROM meta WHERE key=?1", [key], |r| r.get(0))?)
513}
514
515pub(crate) fn current_revision(conn: &Connection) -> Result<i64> { meta(conn, "revision") }
516pub(crate) fn next_revision(conn: &Connection, _record_id: i64) -> Result<i64> {
517 conn.execute("UPDATE meta SET value=value+1 WHERE key='revision'", [])?;
519 current_revision(conn)
520}
521
522pub(crate) fn term_id(conn: &Connection, text_value: &str) -> Result<i64> {
524 let normalized = text::normalized_tag(text_value);
525 conn.execute("INSERT OR IGNORE INTO strings(text) VALUES (?1)", [&normalized])?;
526 Ok(conn.query_row("SELECT id FROM strings WHERE text=?1", [&normalized], |r| r.get(0))?)
527}
528
529pub(crate) fn term_text(conn: &Connection, id: i64) -> Result<String> {
530 Ok(conn.query_row("SELECT text FROM strings WHERE id=?1", [id], |r| r.get(0))?)
531}
532
533pub(crate) fn record_namespaces(conn: &Connection) -> Result<Vec<String>> {
536 let mut stmt = conn.prepare("SELECT DISTINCT s.text FROM records r JOIN strings s ON s.id=r.namespace_id ORDER BY s.text")?;
537 let mut namespaces = Vec::new();
538 for row in stmt.query_map([], |r| r.get::<_, String>(0))? { namespaces.push(row?); }
539 Ok(namespaces)
540}
541
542pub(crate) fn validate_identity(label: &str, value: &str) -> Result<()> {
543 if value.trim().is_empty() || value != value.trim() || value.chars().any(char::is_control) {
544 return Err(Error::Validation(format!("{label} must be nonempty, trimmed, and contain no control characters")));
545 }
546 Ok(())
547}
548pub(crate) fn validate_filter(filter: &ReadFilter) -> Result<()> {
549 validate_identity("namespace", &filter.namespace)?;
550 if filter.scopes.is_empty() { return Err(Error::Validation("at least one explicit read scope is required".into())); }
551 for scope in &filter.scopes { validate_identity("scope", scope)?; }
552 Ok(())
553}
554pub(crate) fn validate_limit(limit: usize) -> Result<()> {
555 if !(1..=10_000).contains(&limit) { return Err(Error::Validation("limit must be between 1 and 10000".into())); }
556 Ok(())
557}
558
559pub(crate) fn normalize_tags(tags: &[String]) -> Vec<String> {
561 tags.iter().map(|label| text::normalized_tag(label)).filter(|tag| !tag.is_empty()).collect::<BTreeSet<_>>().into_iter().collect()
562}
563
564pub(crate) fn tags_prefix(kind: RecordKind, tags: &[String], exclude: &[String], payload: &Value) -> String {
569 let carries = match kind {
570 RecordKind::Note => false,
571 RecordKind::Chunk => payload.get("ordinal").and_then(Value::as_u64) == Some(0),
572 _ => true,
573 };
574 if !carries { return String::new(); }
575 tags.iter().filter(|tag| !exclude.contains(tag)).cloned().collect::<Vec<_>>().join(" ")
576}
577
578pub(crate) fn split_note_path(relative: &str) -> (Vec<String>, String) {
581 let segments: Vec<&str> = relative.split('/').filter(|segment| !segment.is_empty()).collect();
582 let Some((last, dirs)) = segments.split_last() else { return (Vec::new(), String::new()); };
583 let stem = last.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(last).trim();
584 (dirs.iter().map(|segment| segment.to_string()).collect(), stem.to_string())
585}
586
587pub(crate) fn note_path_parts(conn: &Connection, note_id: i64) -> (Vec<String>, String) {
591 let Ok((path, name)) = conn.query_row("SELECT path,name FROM notes WHERE record_id=?1", [note_id],
592 |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) else {
593 return (Vec::new(), String::new());
594 };
595 if Path::new(&path).is_absolute() { return (Vec::new(), name); }
596 (split_note_path(&path).0, name)
597}
598
599pub(crate) fn index_columns(conn: &Connection, kind: RecordKind, payload: &Value) -> (String, String, Vec<String>) {
604 if kind == RecordKind::Chunk {
605 if payload.get("ordinal").and_then(Value::as_u64).unwrap_or(0) != 0 { return (String::new(), String::new(), Vec::new()); }
607 let note_id = payload.get("note_id").and_then(Value::as_i64).unwrap_or(0);
608 let (dirs, stem) = note_path_parts(conn, note_id);
609 let mut exclude = dirs.clone();
610 if !stem.is_empty() { exclude.push(stem.clone()); }
611 return (stem, dirs.join(" "), exclude);
612 }
613 (record_name(kind, payload), String::new(), Vec::new())
614}
615
616pub(crate) fn put_record(conn: &Connection, kind: RecordKind, input: &RecordInput,
617 payload: &Value, text: &str) -> Result<(RecordHeader, crate::index::IndexDocument)> {
618 validate_identity("namespace", &input.namespace)?;
619 validate_identity("scope", &input.scope)?;
620 for evidence in &input.evidence {
621 if evidence.source.trim().is_empty() { return Err(Error::Validation("evidence source is required".into())); }
622 match (evidence.offset, evidence.limit) {
623 (None, None) => {},
624 (Some(offset), Some(limit)) if offset >= 1 && limit >= 1 => {},
625 _ => return Err(Error::Validation("evidence offset/limit must be a 1-based start and a positive line count".into())),
626 }
627 }
628 let namespace_id = term_id(conn, &input.namespace)?;
629 touch_namespace(&input.namespace);
631 let scope_id = term_id(conn, &input.scope)?;
632 let existing = match input.id {
633 Some(id) => Some(conn.query_row("SELECT created_at_us,updated_at_us,revision,scope_id FROM records WHERE id=?1", [id],
634 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, i64>(3)?))).optional()?
635 .ok_or_else(|| Error::NotFound(id.to_string()))?),
636 None => None,
637 };
638 if let (Some(id), true) = (input.id, existing.as_ref().is_some_and(|v| v.3 != scope_id)) {
639 let blocking: i64 = conn.query_row(
642 "SELECT (SELECT COUNT(*) FROM relations WHERE subject_id=?1 OR object_id=?1) \
643 + (SELECT COUNT(*) FROM event_participants WHERE entity_id=?1)", [id], |r| r.get(0))?;
644 if blocking > 0 {
645 return Err(Error::Conflict(format!("record {id} is referenced by {blocking} relation(s) or event participant row(s); remove those references before changing its scope")));
646 }
647 }
648 if let Some(expected) = input.expected_revision {
649 if existing.as_ref().map(|v| v.2) != Some(expected) { return Err(Error::StaleRevision(input.id.map(|v| v.to_string()).unwrap_or_default())); }
650 }
651 let now = now_us();
652 let created = existing.as_ref().map(|v| v.0).unwrap_or(input.created_at_us.unwrap_or(now));
653 let updated = input.updated_at_us.unwrap_or_else(|| now.max(existing.as_ref().map(|v| v.1).unwrap_or(created)));
654 if updated < created { return Err(Error::Validation("updated_at_us precedes created_at_us".into())); }
655 let tags = normalize_tags(&input.tags);
657 let fingerprint = record_fingerprint(text, &tags);
658 let metadata_json = serde_json::to_string(&input.metadata)?;
659 let evidence_json = serde_json::to_string(&input.evidence)?;
660 let payload_json = serde_json::to_string(payload)?;
661 let (id, revision) = match input.id {
662 Some(id) => {
663 let revision = next_revision(conn, id)?;
664 conn.execute("UPDATE records SET namespace_id=?2,kind=?3,scope_id=?4,updated_at_us=?5,revision=?6,metadata_json=?7,
665 evidence_json=?8,fingerprint=?9,payload_json=?10,status=1 WHERE id=?1",
666 params![id, namespace_id, kind.code(), scope_id, updated, revision, metadata_json, evidence_json,
667 fingerprint, payload_json])?;
668 conn.execute("DELETE FROM vectors.embeddings WHERE record_id=?1 AND fingerprint<>?2", params![id, fingerprint])?;
670 (id, revision)
671 }
672 None => {
673 conn.execute("INSERT INTO records(namespace_id,kind,scope_id,created_at_us,updated_at_us,revision,metadata_json,evidence_json,fingerprint,payload_json,status) VALUES (?1,?2,?3,?4,?5,0,?6,?7,?8,?9,1)",
674 params![namespace_id, kind.code(), scope_id, created, updated, metadata_json, evidence_json, fingerprint, payload_json])?;
675 let id = conn.last_insert_rowid();
676 let revision = next_revision(conn, id)?;
677 conn.execute("UPDATE records SET revision=?2 WHERE id=?1", params![id, revision])?;
678 (id, revision)
679 }
680 };
681 let tag_ids = set_record_tags(conn, id, &tags)?;
682 let (name, path, exclude) = index_columns(conn, kind, payload);
685 let document = crate::index::IndexDocument { id, namespace_id, scope_id, kind,
686 text: text.to_string(), name, path,
687 note_id: if kind == RecordKind::Chunk { payload.get("note_id").and_then(Value::as_i64).unwrap_or(0) } else { 0 },
688 tags_prefix: tags_prefix(kind, &tags, &exclude, payload), tag_ids };
689 Ok((RecordHeader { id, namespace: input.namespace.clone(), kind, scope: input.scope.clone(),
690 created_at_us: created, updated_at_us: updated, revision, tags,
691 evidence: input.evidence.clone(), metadata: input.metadata.clone() }, document))
692}
693
694pub(crate) fn record_fingerprint(text: &str, tags: &[String]) -> String {
696 text::digest(&format!("text-v1\n{text}\n{}", tags.join(" ")))
697}
698
699pub(crate) fn set_record_tags(conn: &Connection, id: i64, tags: &[String]) -> Result<Vec<i64>> {
701 conn.execute("DELETE FROM record_tags WHERE record_id=?1", [id])?;
702 let mut tag_ids = Vec::with_capacity(tags.len());
703 for tag in tags {
704 let tag_id = term_id(conn, tag)?;
705 conn.execute("INSERT OR IGNORE INTO record_tags(record_id,tag_id) VALUES (?1,?2)", params![id, tag_id])?;
706 tag_ids.push(tag_id);
707 }
708 Ok(tag_ids)
709}
710
711pub(crate) fn record_tag_pairs(conn: &Connection, id: i64) -> Result<Vec<(i64, String)>> {
713 let mut stmt = conn.prepare("SELECT t.id,t.text FROM record_tags rt JOIN strings t ON t.id=rt.tag_id WHERE rt.record_id=?1 ORDER BY t.text")?;
714 let mut pairs = Vec::new();
715 for row in stmt.query_map([id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? { pairs.push(row?); }
716 Ok(pairs)
717}
718
719pub(crate) fn index_document(conn: &Connection, id: i64, kind: RecordKind, text: String) -> Result<crate::index::IndexDocument> {
721 let (namespace_id, scope_id, payload_json): (i64, i64, String) = conn.query_row("SELECT namespace_id,scope_id,payload_json FROM records WHERE id=?1",
722 [id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
723 let payload: Value = serde_json::from_str(&payload_json)?;
724 let pairs = record_tag_pairs(conn, id)?;
725 let tags: Vec<String> = pairs.iter().map(|(_, tag)| tag.clone()).collect();
726 let (name, path, exclude) = index_columns(conn, kind, &payload);
727 Ok(crate::index::IndexDocument { id, namespace_id, scope_id, kind, text,
728 name, path,
729 note_id: if kind == RecordKind::Chunk { payload.get("note_id").and_then(Value::as_i64).unwrap_or(0) } else { 0 },
730 tags_prefix: tags_prefix(kind, &tags, &exclude, &payload),
731 tag_ids: pairs.into_iter().map(|(tag_id, _)| tag_id).collect() })
732}
733
734pub(crate) fn chunk_notes(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, (i64, usize)>> {
738 let mut out = BTreeMap::new();
739 if ids.is_empty() { return Ok(out); }
740 let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
741 let mut stmt = conn.prepare(&format!("SELECT record_id,note_id,\"offset\" FROM chunks WHERE record_id IN ({placeholders})"))?;
742 for row in stmt.query_map(params_from_iter(ids.iter().copied()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)))? {
743 let (id, note_id, offset) = row?;
744 out.insert(id, (note_id, offset.max(0) as usize));
745 }
746 Ok(out)
747}
748
749pub(crate) fn namespace_root(conn: &Connection, namespace_id: i64) -> Result<Option<String>> {
751 Ok(conn.query_row("SELECT root FROM namespace_roots WHERE namespace_id=?1", [namespace_id], |r| r.get(0)).optional()?)
752}
753
754pub(crate) fn absolute_note_path(conn: &Connection, namespace_id: i64, stored: &str) -> String {
756 match namespace_root(conn, namespace_id) {
757 Ok(Some(root)) => Path::new(&root).join(stored.replace('/', std::path::MAIN_SEPARATOR_STR)).to_string_lossy().into_owned(),
758 _ => stored.to_string(),
759 }
760}
761
762pub(crate) fn record_value(conn: &Connection, key: &RecordKey) -> Result<Option<Value>> {
763 Ok(record_values(conn, &[key.id])?.remove(&key.id))
764}
765
766pub(crate) fn record_values(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, Value>> {
769 let mut out = BTreeMap::new();
770 if ids.is_empty() { return Ok(out); }
771 let placeholders = vec!["?"; ids.len()].join(",");
772 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
773 let mut stmt = conn.prepare(&format!("SELECT r.id,r.namespace_id,n.text,r.kind,s.text,r.created_at_us,r.updated_at_us,r.revision,
774 r.metadata_json,r.evidence_json,r.payload_json FROM records r
775 JOIN strings n ON n.id=r.namespace_id JOIN strings s ON s.id=r.scope_id WHERE r.id IN ({placeholders}) ORDER BY r.id"))?;
776 let mut rows: Vec<(i64, i64, String, i64, String, i64, i64, i64, String, String, String)> = Vec::new();
777 for row in stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?,
778 r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, String>(4)?, r.get::<_, i64>(5)?, r.get::<_, i64>(6)?,
779 r.get::<_, i64>(7)?, r.get::<_, String>(8)?, r.get::<_, String>(9)?, r.get::<_, String>(10)?)))? {
780 rows.push(row?);
781 }
782 let mut tags_stmt = conn.prepare(&format!("SELECT rt.record_id,t.text FROM record_tags rt JOIN strings t ON t.id=rt.tag_id \
783 WHERE rt.record_id IN ({placeholders}) ORDER BY rt.record_id,t.text"))?;
784 let mut tags: BTreeMap<i64, Vec<String>> = BTreeMap::new();
785 for row in tags_stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
786 let (id, tag) = row?;
787 tags.entry(id).or_default().push(tag);
788 }
789 let mut note_meta: BTreeMap<i64, (String, String)> = BTreeMap::new();
792 {
793 let mut stmt = conn.prepare(&format!("SELECT n.record_id,n.path,n.name FROM notes n \
794 WHERE n.record_id IN ({placeholders})"))?;
795 for row in stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))? {
796 let (id, path, name) = row?;
797 note_meta.insert(id, (path, name));
798 }
799 }
800 for (id, namespace_id, namespace, kind_code, scope, created, updated, revision, metadata, evidence, payload) in rows {
801 let kind = RecordKind::from_code(kind_code).ok_or_else(|| Error::Validation("invalid stored record kind".into()))?;
802 let header = RecordHeader { id, namespace, kind, scope,
803 created_at_us: created, updated_at_us: updated, revision, tags: tags.remove(&id).unwrap_or_default(),
804 metadata: serde_json::from_str(&metadata)?, evidence: serde_json::from_str(&evidence)? };
805 let mut value = serde_json::to_value(header)?;
806 let object = value.as_object_mut().ok_or_else(|| Error::Validation("invalid stored header".into()))?;
807 let mut payload: Metadata = serde_json::from_str(&payload)?;
808 if let Some(type_id) = payload.get("memory_type_id").and_then(Value::as_i64) {
810 payload.insert("memory_type".into(), Value::String(term_text(conn, type_id)?));
811 payload.remove("memory_type_id");
812 }
813 if kind == RecordKind::Note {
814 let (stored, name) = note_meta.remove(&id).unwrap_or_default();
816 let source = absolute_note_path(conn, namespace_id, &stored);
817 payload.insert("source".into(), Value::String(source));
818 payload.insert("title".into(), Value::String(name));
819 }
820 object.extend(payload);
821 out.insert(id, value);
822 }
823 Ok(out)
824}
825
826pub(crate) fn matches_filter(conn: &Connection, key: &RecordKey, filter: &ReadFilter) -> Result<bool> {
827 validate_filter(filter)?;
828 let row: Option<(i64, i64)> = conn.query_row("SELECT namespace_id,scope_id FROM records WHERE id=?1", [key.id],
829 |r| Ok((r.get(0)?, r.get(1)?))).optional()?;
830 let Some((namespace_id, scope_id)) = row else { return Ok(false) };
831 if term_text(conn, namespace_id)? != text::normalized_tag(&filter.namespace) { return Ok(false); }
832 let scope = term_text(conn, scope_id)?;
833 if !filter.scopes.iter().any(|s| text::normalized_tag(s) == scope) { return Ok(false); }
834 for tag in &filter.tags {
835 let exists: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM record_tags rt JOIN strings t ON t.id=rt.tag_id WHERE rt.record_id=?1 AND t.text=?2)",
836 params![key.id, text::normalized_tag(tag)], |r| r.get(0))?;
837 if !exists { return Ok(false); }
838 }
839 Ok(true)
840}
841
842pub(crate) fn get<T: DeserializeOwned>(conn: &Connection, key: &RecordKey, filter: &ReadFilter) -> Result<T> {
843 if !matches_filter(conn, key, filter)? { return Err(Error::NotFound(key.id.to_string())); }
844 serde_json::from_value(record_value(conn, key)?.ok_or_else(|| Error::NotFound(key.id.to_string()))?).map_err(Error::from)
845}
846
847pub(crate) fn load_many<T: DeserializeOwned>(conn: &Connection, ids: &[i64], filter: &ReadFilter) -> Result<BTreeMap<i64, T>> {
850 let mut out = BTreeMap::new();
851 if ids.is_empty() { return Ok(out); }
852 validate_filter(filter)?;
853 let (condition, values) = filter_sql(filter, &[], true)?;
854 let placeholders = vec!["?"; ids.len()].join(",");
855 let mut stmt = conn.prepare(&format!("SELECT r.id FROM records r WHERE r.id IN ({placeholders}) AND {condition} ORDER BY r.id"))?;
856 let params = ids.iter().map(|id| SqlValue::Integer(*id)).chain(values).collect::<Vec<_>>();
857 let allowed = stmt.query_map(params_from_iter(params), |r| r.get::<_, i64>(0))?.collect::<std::result::Result<Vec<_>, _>>()?;
858 for (id, value) in record_values(conn, &allowed)? {
859 out.insert(id, serde_json::from_value(value)?);
860 }
861 Ok(out)
862}
863
864pub(crate) fn filter_sql(filter: &ReadFilter, kinds: &[RecordKind], by_ids: bool) -> Result<(String, Vec<SqlValue>)> {
872 validate_filter(filter)?;
873 let mut query = if by_ids {
874 "+r.namespace_id=(SELECT id FROM strings WHERE text=?)".to_string()
875 } else {
876 "r.namespace_id=(SELECT id FROM strings WHERE text=?)".to_string()
877 };
878 let mut values = vec![SqlValue::Text(text::normalized_tag(&filter.namespace))];
879 query.push_str(" AND r.scope_id IN (SELECT id FROM strings WHERE text IN (");
880 query.push_str(&vec!["?"; filter.scopes.len()].join(",")); query.push_str("))");
881 values.extend(filter.scopes.iter().map(|s| SqlValue::Text(text::normalized_tag(s))));
882 if !kinds.is_empty() {
883 query.push_str(" AND r.kind IN ("); query.push_str(&vec!["?"; kinds.len()].join(",")); query.push(')');
884 values.extend(kinds.iter().map(|k| SqlValue::Integer(k.code())));
885 }
886 for tag in &filter.tags {
887 query.push_str(" AND EXISTS(SELECT 1 FROM record_tags rt JOIN strings t ON t.id=rt.tag_id WHERE rt.record_id=r.id AND t.text=?)");
888 values.push(SqlValue::Text(text::normalized_tag(tag)));
889 }
890 Ok((query, values))
891}
892
893pub(crate) fn count_matches(conn: &Connection, filter: &ReadFilter, kinds: &[RecordKind]) -> Result<usize> {
895 let (condition, values) = filter_sql(filter, kinds, false)?;
896 let count: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM records r WHERE {condition}"), params_from_iter(values), |r| r.get(0))?;
897 Ok(count as usize)
898}
899
900pub(crate) fn record_text(kind: RecordKind, payload: &Value) -> String {
904 let field = |key: &str| payload.get(key).and_then(Value::as_str).unwrap_or("").to_string();
905 match kind {
906 RecordKind::Memory => field("judgment"),
907 RecordKind::Entity => entity_body(payload),
908 RecordKind::Relation => format!("{} {} {} {}", field("subject_name"), field("predicate"), field("object_name"), field("reason")),
909 RecordKind::Event => format!("{} {} {} {}", field("name"), field("summary"), name_list(payload), field("reason")),
910 RecordKind::Note | RecordKind::Chunk => String::new(),
912 }
913}
914
915pub(crate) fn record_name(kind: RecordKind, payload: &Value) -> String {
917 match kind {
918 RecordKind::Entity => payload.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
919 _ => String::new(),
920 }
921}
922
923pub(crate) fn event_text_lengths(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, usize>> {
930 let mut out = BTreeMap::new();
931 if ids.is_empty() { return Ok(out); }
932 let placeholders = vec!["?"; ids.len()].join(",");
933 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
934 let field = |name: &str| format!(
935 "CASE WHEN json_type(r.payload_json,'$.{name}')='text' THEN json_extract(r.payload_json,'$.{name}') ELSE '' END");
936 let names = "COALESCE(CASE WHEN json_type(r.payload_json,'$.participant_names')='array' \
939 THEN (SELECT group_concat(j.value,' ') FROM json_each(r.payload_json,'$.participant_names') j \
940 WHERE j.type='text') ELSE '' END,'')";
941 let mut stmt = conn.prepare(&format!(
942 "SELECT r.id, LENGTH({} || ' ' || {} || ' ' || {names} || ' ' || {}) \
943 FROM records r WHERE r.id IN ({placeholders}) ORDER BY r.id",
944 field("name"), field("summary"), field("reason")))?;
945 for row in stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))? {
946 let (id, length) = row?;
947 out.insert(id, length.max(0) as usize);
948 }
949 Ok(out)
950}
951
952pub(crate) fn entity_names(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, String>> {
955 let mut out = BTreeMap::new();
956 if ids.is_empty() { return Ok(out); }
957 let placeholders = vec!["?"; ids.len()].join(",");
958 let mut stmt = conn.prepare(&format!("SELECT record_id,name FROM entities WHERE record_id IN ({placeholders})"))?;
959 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
960 for row in stmt.query_map(params_from_iter(params), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
961 let (id, name) = row?;
962 out.insert(id, name);
963 }
964 Ok(out)
965}
966
967fn name_list(payload: &Value) -> String {
968 payload.get("participant_names").and_then(Value::as_array)
969 .map(|names| names.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "))
970 .unwrap_or_default()
971}
972
973fn entity_body(payload: &Value) -> String {
974 let aliases = payload.get("aliases").and_then(Value::as_array)
975 .map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" ")).unwrap_or_default();
976 let summary = payload.get("summary").and_then(Value::as_str).unwrap_or("");
977 let attr_text = payload.get("attributes").and_then(Value::as_object).map(|attrs| {
978 attrs.iter().map(|(key, values)| {
979 let joined = values.as_array().map(|v| v.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" ")).unwrap_or_default();
980 format!("{key} {joined}")
981 }).collect::<Vec<_>>().join(" ")
982 }).unwrap_or_default();
983 format!("{aliases} {summary} {attr_text}")
984}
985
986pub(crate) fn select_keys(conn: &Connection, filter: &ReadFilter, kinds: &[RecordKind], limit: usize, after: Option<&str>) -> Result<Vec<RecordKey>> { let (mut condition, mut values) = filter_sql(filter, kinds, false)?;
987 if let Some(cursor) = after {
988 let id: i64 = cursor.parse().map_err(|_| Error::Validation("invalid page cursor".into()))?;
989 condition.push_str(" AND r.id>?");
990 values.push(SqlValue::Integer(id));
991 }
992 values.push(SqlValue::Integer(limit.min(i64::MAX as usize) as i64));
993 let mut stmt = conn.prepare(&format!("SELECT r.id FROM records r WHERE {condition} ORDER BY r.id LIMIT ?"))?;
994 let rows = stmt.query_map(params_from_iter(values), |r| r.get::<_, i64>(0))?;
995 let mut keys = Vec::new();
996 for row in rows { keys.push(RecordKey { id: row? }); }
997 Ok(keys)
998}
999
1000pub(crate) fn list<T: DeserializeOwned>(conn: &Connection, kind: RecordKind, request: &PageRequest) -> Result<Page<T>> {
1001 validate_limit(request.limit)?;
1002 let mut keys = select_keys(conn, &request.filter, &[kind], request.limit + 1, request.after.as_deref())?;
1003 let has_more = keys.len() > request.limit;
1004 keys.truncate(request.limit);
1005 let next_cursor = if has_more { keys.last().map(RecordKey::index_key) } else { None };
1006 let ids: Vec<i64> = keys.iter().map(|key| key.id).collect();
1008 let mut loaded: BTreeMap<i64, T> = load_many(conn, &ids, &request.filter)?;
1009 let items = keys.into_iter().filter_map(|key| loaded.remove(&key.id)).collect::<Vec<_>>();
1010 Ok(Page { items, next_cursor })
1011}
1012
1013pub(crate) fn delete_record(conn: &Connection, key: &RecordKey) -> Result<bool> {
1014 let namespace = namespace_of(conn, key.id)?;
1017 let changed = conn.execute("DELETE FROM records WHERE id=?1", [key.id]);
1018 let changed = match changed {
1019 Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation =>
1020 return Err(Error::Conflict(format!("record {} is still referenced", key.id))),
1021 other => other?,
1022 };
1023 if changed > 0 {
1024 conn.execute("DELETE FROM vectors.embeddings WHERE record_id=?1", [key.id])?;
1026 next_revision(conn, key.id)?;
1027 if let Some(namespace) = namespace { touch_namespace(&namespace); }
1028 }
1029 Ok(changed > 0)
1030}
1031
1032pub(crate) const MARK_WRITING: i64 = 1;
1035pub(crate) const MARK_DELETING: i64 = 2;
1036
1037pub(crate) fn mark_records(tx: &Transaction, ids: &[i64], status: i64) -> Result<()> {
1040 if ids.is_empty() { return Ok(()); }
1041 let placeholders = vec!["?"; ids.len()].join(",");
1042 let values: Vec<SqlValue> = std::iter::once(SqlValue::Integer(status))
1043 .chain(ids.iter().map(|id| SqlValue::Integer(*id))).collect();
1044 tx.execute(&format!("UPDATE records SET status=?1 WHERE id IN ({placeholders})"), params_from_iter(values))?;
1045 Ok(())
1046}
1047
1048pub(crate) fn clear_writing_marks(conn: &Connection) -> Result<()> {
1052 conn.execute("UPDATE records SET status=0 WHERE status=1", [])?;
1053 Ok(())
1054}
1055
1056fn recover_marks(conn: &Connection, index: &crate::index::TextIndex) -> Result<()> {
1062 let marked: Vec<(i64, i64, i64)> = {
1063 let mut stmt = conn.prepare("SELECT id,kind,status FROM records WHERE status<>0")?;
1064 let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)))?;
1065 rows.collect::<std::result::Result<Vec<_>, _>>()?
1066 };
1067 if marked.is_empty() { return Ok(()); }
1068 let mut deleting: Vec<(i64, i64)> = marked.iter().filter(|(_, _, s)| *s == MARK_DELETING)
1070 .map(|(id, kind, _)| (*id, *kind)).collect();
1071 deleting.sort_by(|a, b| b.1.cmp(&a.1));
1072 let mut dropped: Vec<i64> = Vec::new();
1073 for (id, _) in &deleting {
1074 if delete_record(conn, &RecordKey { id: *id }).is_ok() { dropped.push(*id); }
1075 }
1076 index.stage_deletions(&dropped)?;
1077 let writing: Vec<i64> = marked.iter().filter(|(_, _, s)| *s == MARK_WRITING).map(|(id, _, _)| *id).collect();
1079 index.stage_records(conn, &writing)?;
1080 index.sync(conn)?;
1082 clear_writing_marks(conn)
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use super::*;
1088
1089 fn batched_load_plan(conn: &Connection, ids: &[i64], by_ids: bool) -> String {
1091 let (condition, values) = filter_sql(&ReadFilter::default(), &[], by_ids).unwrap();
1092 let placeholders = vec!["?"; ids.len()].join(",");
1093 let sql = format!("EXPLAIN QUERY PLAN SELECT r.id FROM records r WHERE r.id IN ({placeholders}) AND {condition} ORDER BY r.id");
1094 let params: Vec<SqlValue> = ids.iter().map(|id| SqlValue::Integer(*id)).chain(values).collect();
1095 let mut stmt = conn.prepare(&sql).unwrap();
1096 let plans: Vec<String> = stmt.query_map(params_from_iter(params), |row| row.get::<_, String>(3))
1097 .unwrap().map(|row| row.unwrap()).collect();
1098 plans.join(" | ")
1099 }
1100
1101 fn seed(kb: &KnowledgeBase, rows: i64) {
1102 let inputs: Vec<crate::MemoryInput> = (1..=rows).map(|i| crate::MemoryInput::new(format!("记录 {i}"))).collect();
1103 kb.memories().upsert_many(&inputs).unwrap();
1104 }
1105
1106 fn sample_ids() -> Vec<i64> { (1..=10).collect() }
1107
1108 #[test]
1111 fn batched_load_stays_on_the_primary_key() {
1112 let dir = tempfile::tempdir().unwrap();
1113 let kb = KnowledgeBase::open(dir.path()).unwrap();
1114 seed(&kb, 100);
1115 let guard = kb.read().unwrap();
1116 let plan = batched_load_plan(guard.conn(), &sample_ids(), true);
1117 assert!(plan.contains("INTEGER PRIMARY KEY"), "批量取回退化为扫索引:{plan}");
1118 }
1119
1120 #[test]
1123 fn event_text_lengths_match_record_text() {
1124 let dir = tempfile::tempdir().unwrap();
1125 let kb = KnowledgeBase::open(dir.path()).unwrap();
1126 let entity = |name: &str| crate::EntityInput { record: Default::default(), name: name.into(),
1127 entity_type: "person".into(), aliases: vec![], attributes: BTreeMap::new(), summary: String::new() };
1128 let created = kb.graph().apply_batch(&crate::GraphBatch {
1129 entities: vec![entity("甲"), entity("乙")], ..Default::default()
1130 }).unwrap().value;
1131 let (first, second) = (created.entities[0].header.id, created.entities[1].header.id);
1132 let created = kb.graph().apply_batch(&crate::GraphBatch {
1133 events: vec![
1134 crate::EventInput { record: Default::default(), name: "别鹤典仪".into(), summary: "两人同去".into(),
1135 participants: vec![first, second], confidence: 1.0, reason: "有人证".into() },
1136 crate::EventInput { record: Default::default(), name: "堂中自语".into(), summary: String::new(),
1137 participants: vec![first], confidence: 1.0, reason: String::new() },
1138 ], ..Default::default()
1139 }).unwrap().value;
1140 let ids: Vec<i64> = created.events.iter().map(|event| event.header.id).collect();
1141
1142 {
1144 let raw = Connection::open(dir.path().join("store.sqlite3")).unwrap();
1145 let payloads = [
1146 r#"{"name":7,"summary":"只剩数字名","participant_names":"甲 乙","reason":null}"#,
1147 r#"{"name":"正常","summary":null,"participant_names":["甲",7,"乙"],"reason":"理由"}"#,
1148 ];
1149 for (id, payload) in ids.iter().zip(payloads) {
1150 raw.execute("UPDATE records SET payload_json=?1 WHERE id=?2", params![payload, id]).unwrap();
1151 }
1152 }
1153
1154 let guard = kb.read().unwrap();
1155 let conn = guard.conn();
1156 let lengths = event_text_lengths(conn, &ids).unwrap();
1157 assert_eq!(lengths.len(), ids.len(), "每条事件都该有长度");
1158 for id in ids {
1159 let payload = record_values(conn, &[id]).unwrap().remove(&id).unwrap();
1160 assert_eq!(lengths[&id], record_text(RecordKind::Event, &payload).chars().count(),
1161 "事件 {id} 的 SQL 长度与 record_text 不一致");
1162 }
1163 }
1164
1165 fn fixture_space() -> crate::embeddings::EmbeddingSpace {
1167 crate::embeddings::EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(),
1168 dimension: 2, text_version: 1, encoding: "f32".into() }
1169 }
1170
1171 fn cache_partition(kb: &KnowledgeBase, space: &crate::embeddings::EmbeddingSpace, namespace: &str) {
1173 kb.partition(space, namespace, "public").unwrap();
1174 }
1175
1176 fn cached_namespaces(kb: &KnowledgeBase) -> BTreeSet<String> {
1178 kb.engine.vectors.entries.lock().keys().map(|(_, namespace, _)| namespace.clone()).collect()
1179 }
1180
1181 fn namespace_filter(namespace: &str) -> ReadFilter {
1182 ReadFilter { namespace: namespace.into(), scopes: vec!["public".into()], tags: vec![], note_ids: vec![] }
1183 }
1184
1185 #[test]
1187 fn invalidating_one_namespace_leaves_the_others_alone() {
1188 let cache = VectorCache::new();
1189 let key = |namespace: &str| ("v".to_string(), namespace.to_string(), "public".to_string());
1190 cache.entries.lock().insert(key("a"), None);
1191 cache.entries.lock().insert(key("b"), None);
1192 let epoch_b = cache.epoch_of("b");
1193
1194 cache.invalidate_namespaces(&HashSet::from(["a".to_string()]));
1195
1196 assert!(cache.entries.lock().get(&key("a")).is_none(), "写过的领域要清掉条目");
1197 assert!(cache.entries.lock().get(&key("b")).is_some(), "没写过的领域不该被牵连");
1198 assert_eq!(cache.epoch_of("b"), epoch_b, "没写过的领域版本号不动");
1199 assert_ne!(cache.epoch_of("a"), epoch_b, "写过的领域版本号要前进,在途载入才会作废");
1200
1201 let epoch_a = cache.epoch_of("a");
1203 cache.invalidate();
1204 assert!(cache.entries.lock().is_empty());
1205 assert!(cache.epoch_of("a") > epoch_a && cache.epoch_of("b") > epoch_b);
1206 }
1207
1208 #[test]
1210 fn writing_one_namespace_keeps_other_vector_partitions_cached() {
1211 let dir = tempfile::tempdir().unwrap();
1212 let kb = KnowledgeBase::open(dir.path()).unwrap();
1213 let space = fixture_space();
1214 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1215 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["a".to_string(), "b".to_string()]));
1216
1217 let mut input = crate::MemoryInput::new("写在 a 领域的一条");
1218 input.record.namespace = "a".into();
1219 kb.memories().upsert(input).unwrap();
1220
1221 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["b".to_string()]), "只该清掉被写的那个领域");
1222 }
1223
1224 #[test]
1226 fn deleting_a_record_evicts_only_its_own_namespace() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let kb = KnowledgeBase::open(dir.path()).unwrap();
1229 let mut input = crate::MemoryInput::new("要被删掉的一条");
1230 input.record.namespace = "a".into();
1231 let id = kb.memories().upsert(input).unwrap().value.header.id;
1232
1233 let space = fixture_space();
1234 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1235 kb.memories().delete(&[id], &namespace_filter("a")).unwrap();
1236
1237 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["b".to_string()]), "删掉的领域要清,别的领域留着");
1238 }
1239
1240 #[test]
1243 fn filling_vectors_only_evicts_the_namespaces_it_wrote() {
1244 let dir = tempfile::tempdir().unwrap();
1245 let kb = KnowledgeBase::open(dir.path()).unwrap();
1246 let space = fixture_space();
1247 kb.embeddings().register_space(space.clone()).unwrap();
1248 kb.embeddings().register_embedder("v", |texts: &[String]| -> std::result::Result<Vec<Vec<f32>>, crate::EmbedCallbackError> {
1249 Ok(texts.iter().map(|_| vec![1.0f32, 0.0]).collect())
1250 }).unwrap();
1251 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1252 kb.memories().upsert(crate::MemoryInput::new("补齐用的一条")).unwrap();
1253 kb.embeddings().sync("v", 32).unwrap();
1254
1255 let cached = cached_namespaces(&kb);
1256 assert!(cached.contains("a") && cached.contains("b"),
1257 "补齐只写了 default 领域,a 与 b 的分区缓存不该被牵连:{cached:?}");
1258 }
1259
1260 #[test]
1263 fn an_unregistered_write_falls_back_to_invalidating_everything() {
1264 let dir = tempfile::tempdir().unwrap();
1265 let kb = KnowledgeBase::open(dir.path()).unwrap();
1266 let space = fixture_space();
1267 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1268
1269 kb.mutate(|tx| Ok((tx.execute("INSERT INTO meta(key,value) VALUES ('cache_probe',1)
1271 ON CONFLICT(key) DO UPDATE SET value=excluded.value", [])?, Vec::new()))).unwrap();
1272
1273 assert!(cached_namespaces(&kb).is_empty(), "登记为空却改过行时必须整体失效");
1274 }
1275}