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 }
18
19pub(crate) struct Readers { pub idle: Vec<Connection> }
22
23pub(crate) struct VectorCache {
28 generation: AtomicU64,
30 bumps: Mutex<HashMap<String, u64>>,
32 entries: Mutex<HashMap<(String, String, String), Option<Arc<crate::embeddings::Partition>>>>,
33}
34
35impl VectorCache {
36 fn new() -> Self {
37 Self { generation: AtomicU64::new(0), bumps: Mutex::new(HashMap::new()), entries: Mutex::new(HashMap::new()) }
38 }
39 pub fn epoch_of(&self, namespace: &str) -> u64 {
41 self.generation.load(Ordering::SeqCst) + self.bumps.lock().get(namespace).copied().unwrap_or(0)
42 }
43 pub fn invalidate(&self) {
46 self.generation.fetch_add(1, Ordering::SeqCst);
47 self.entries.lock().clear();
48 }
49 pub fn invalidate_namespaces(&self, namespaces: &HashSet<String>) {
51 {
52 let mut bumps = self.bumps.lock();
53 for namespace in namespaces { *bumps.entry(namespace.clone()).or_insert(0) += 1; }
54 }
55 self.entries.lock().retain(|(_, namespace, _), _| !namespaces.contains(namespace));
56 }
57}
58
59thread_local! {
60 static TOUCHED_NAMESPACES: std::cell::RefCell<Option<HashSet<String>>> = const { std::cell::RefCell::new(None) };
64}
65
66pub(crate) fn touch_namespace(namespace: &str) {
69 TOUCHED_NAMESPACES.with(|slot| {
70 if let Some(touched) = slot.borrow_mut().as_mut() { touched.insert(text::normalized_tag(namespace)); }
71 });
72}
73
74pub(crate) fn touch_record_namespace(conn: &Connection, record_id: i64) -> Result<()> {
76 if let Some(namespace) = namespace_of(conn, record_id)? { touch_namespace(&namespace); }
77 Ok(())
78}
79
80pub(crate) fn namespace_of(conn: &Connection, record_id: i64) -> Result<Option<String>> {
82 Ok(conn.query_row("SELECT s.text FROM records r JOIN strings s ON s.id=r.namespace_id WHERE r.id=?1",
83 [record_id], |r| r.get(0)).optional()?)
84}
85
86struct TouchLog(Option<HashSet<String>>);
88
89impl TouchLog {
90 fn install() -> Self {
91 Self(TOUCHED_NAMESPACES.with(|slot| slot.borrow_mut().replace(HashSet::new())))
92 }
93 fn take(&self) -> HashSet<String> {
95 TOUCHED_NAMESPACES.with(|slot| slot.borrow_mut().take()).unwrap_or_default()
96 }
97}
98
99impl Drop for TouchLog {
100 fn drop(&mut self) {
101 let previous = self.0.take();
102 TOUCHED_NAMESPACES.with(|slot| *slot.borrow_mut() = previous);
103 }
104}
105
106pub(crate) struct Engine {
107 pub writer: Mutex<Option<Writer>>,
108 pub readers: Mutex<Option<Readers>>,
109 pub vector_writer: Mutex<Option<Writer>>,
112 pub vector_readers: Mutex<Option<Readers>>,
113 pub index: RwLock<Option<Arc<TextIndex>>>,
117 pub vectors: VectorCache,
118 pub embedders: crate::embeddings::EmbedderRegistry,
120 pub rerankers: crate::search::RerankerRegistry,
121 pub events: crate::events::EventRegistry,
123 pub vectorizer: std::sync::OnceLock<Arc<crate::embeddings::Vectorizer>>,
125 pub degraded: Mutex<Vec<Degrade>>,
127 pub root: PathBuf,
128}
129
130fn open_reader(root: &Path) -> Result<Connection> {
132 let conn = Connection::open(root.join("store.sqlite3"))?;
133 conn.execute_batch("PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?;
134 conn.execute("ATTACH DATABASE ?1 AS vectors", [root.join("vectors.sqlite3").to_string_lossy().to_string()])?;
136 Ok(conn)
137}
138
139fn open_vector_writer(root: &Path) -> Result<Connection> {
142 let conn = Connection::open(root.join("vectors.sqlite3"))?;
143 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;")?;
144 conn.execute_batch(include_str!("vectors_schema.sql"))?;
145 Ok(conn)
146}
147fn open_vector_reader(root: &Path) -> Result<Connection> {
148 let conn = Connection::open(root.join("vectors.sqlite3"))?;
149 conn.execute_batch("PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL;")?;
150 Ok(conn)
151}
152
153#[derive(Clone)]
155pub struct KnowledgeBase { pub(crate) engine: Arc<Engine> }
156
157pub(crate) struct ReadGuard<'a> { engine: &'a Engine, conn: Option<Connection> }
159
160impl ReadGuard<'_> {
161 pub fn conn(&self) -> &Connection { self.conn.as_ref().expect("read connection lives until drop") }
162}
163
164impl Drop for ReadGuard<'_> {
165 fn drop(&mut self) {
166 let Some(conn) = self.conn.take() else { return };
167 if let Some(readers) = self.engine.readers.lock().as_mut() { readers.idle.push(conn); }
169 }
170}
171
172impl KnowledgeBase {
173 pub fn open(directory: impl AsRef<Path>) -> Result<Self> {
174 std::fs::create_dir_all(directory.as_ref())?;
175 let root = std::fs::canonicalize(directory.as_ref())?;
176 let file_lock = OpenOptions::new().create(true).truncate(false).read(true).write(true).open(root.join("writer.lock"))?;
177 if !file_lock.try_lock_exclusive()? { return Err(Error::Locked(root.display().to_string())); }
178 let mut write_conn = Connection::open(root.join("store.sqlite3"))?;
179 schema::initialize(&mut write_conn)?;
180 let needs_vector_migration: bool = write_conn.query_row(
183 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='embeddings'", [], |r| r.get::<_, i64>(0)).map(|n| n > 0)?;
184 if needs_vector_migration { crate::embeddings::migrate_vectors(&root, &mut write_conn)?; }
185 let vector_writer = open_vector_writer(&root)?;
187 write_conn.execute("ATTACH DATABASE ?1 AS vectors", [root.join("vectors.sqlite3").to_string_lossy().to_string()])?;
189 let index = Arc::new(TextIndex::open(&root)?);
190 index.recover(&write_conn)?;
191 let reader = open_reader(&root)?;
193 let vector_reader = open_vector_reader(&root)?;
194 let engine = Arc::new(Engine {
195 writer: Mutex::new(Some(Writer { conn: write_conn, _file_lock: file_lock })),
196 readers: Mutex::new(Some(Readers { idle: vec![reader] })),
197 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"))? })),
198 vector_readers: Mutex::new(Some(Readers { idle: vec![vector_reader] })),
199 index: RwLock::new(Some(index)), vectors: VectorCache::new(),
200 embedders: crate::embeddings::EmbedderRegistry::new(),
201 rerankers: crate::search::RerankerRegistry::new(),
202 events: crate::events::EventRegistry::default(),
203 vectorizer: std::sync::OnceLock::new(),
204 degraded: Mutex::new(Vec::new()), root,
205 });
206 engine.vectorizer.set(crate::embeddings::Vectorizer::start(&engine)?).unwrap_or_else(|_| unreachable!("vectorizer starts once"));
208 Ok(Self { engine })
209 }
210
211 pub fn directory(&self) -> &Path { &self.engine.root }
212
213 pub(crate) fn index(&self) -> Result<Arc<TextIndex>> {
215 self.engine.index.read().clone().ok_or(Error::Closed)
216 }
217
218 pub(crate) fn index_documents(&self, docs: &[crate::index::IndexDocument]) -> Result<()> {
221 self.index()?.stage(docs)
222 }
223
224 pub fn close(&self) -> Result<()> {
225 if let Some(vectorizer) = self.engine.vectorizer.get() { vectorizer.stop(); }
227 let mut guard = self.engine.writer.lock();
228 let result = match guard.as_ref() {
229 Some(writer) => self.index()?.sync(&writer.conn),
230 None => Ok(()),
231 };
232 *guard = None;
233 *self.engine.vector_writer.lock() = None;
234 *self.engine.vector_readers.lock() = None;
235 *self.engine.index.write() = None;
237 *self.engine.readers.lock() = None;
238 result
239 }
240
241 pub(crate) fn read(&self) -> Result<ReadGuard<'_>> {
243 let conn = {
244 let mut readers = self.engine.readers.lock();
245 match readers.as_mut() {
246 Some(readers) => match readers.idle.pop() {
247 Some(conn) => conn,
248 None => open_reader(&self.engine.root)?,
249 },
250 None => return Err(Error::Closed),
251 }
252 };
253 Ok(ReadGuard { engine: &self.engine, conn: Some(conn) })
254 }
255
256 pub(crate) fn partition(&self, space: &crate::embeddings::EmbeddingSpace,
260 namespace: &str, scope: &str) -> Result<Option<Arc<crate::embeddings::Partition>>> {
261 let key = (space.id.clone(), namespace.to_string(), scope.to_string());
262 let epoch = self.engine.vectors.epoch_of(namespace);
263 let cached = self.engine.vectors.entries.lock().get(&key).cloned();
264 if let Some(partition) = cached { return Ok(partition); }
265 let loaded = {
266 let conn = {
267 let mut readers = self.engine.vector_readers.lock();
268 match readers.as_mut() {
269 Some(readers) => readers.idle.pop().unwrap_or_else(|| open_vector_reader(&self.engine.root).unwrap_or_else(|_| unreachable!())),
270 None => return Err(Error::Closed),
271 }
272 };
273 let result = crate::embeddings::Partition::load(&conn, space, namespace, scope)?;
274 if let Some(readers) = self.engine.vector_readers.lock().as_mut() { readers.idle.push(conn); }
275 result.map(Arc::new)
276 };
277 {
281 let mut entries = self.engine.vectors.entries.lock();
282 if self.engine.vectors.epoch_of(namespace) == epoch { entries.insert(key, loaded.clone()); }
283 }
284 Ok(loaded)
285 }
286
287 pub(crate) fn sync_index_if_behind(&self, conn: &Connection) -> Result<()> {
293 let pending: i64 = conn.query_row("SELECT COUNT(*) FROM index_updates", [], |r| r.get(0))?;
294 if pending == 0 { return Ok(()); }
295 if let Some(mut guard) = self.engine.writer.try_lock() {
298 if let Some(writer) = guard.as_mut() { self.index()?.sync(&writer.conn)?; }
299 }
300 Ok(())
301 }
302
303 pub(crate) fn mutate<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<T>) -> Result<WriteReceipt<T>> {
305 let mut guard = self.engine.writer.lock();
306 let writer = guard.as_mut().ok_or(Error::Closed)?;
307 let changed_before = writer.conn.total_changes();
308 let log = TouchLog::install();
309 let tx = writer.conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
310 let value = f(&tx)?;
311 let revision = current_revision(&tx)?;
312 tx.commit()?;
313 let touched = log.take();
314 drop(log);
315 if touched.is_empty() {
319 if writer.conn.total_changes() > changed_before { self.engine.vectors.invalidate(); }
322 } else {
323 self.engine.vectors.invalidate_namespaces(&touched);
324 }
325 self.invalidate_readiness(&writer.conn);
328 if let Some(vectorizer) = self.engine.vectorizer.get() { vectorizer.notify_work(); }
330 Ok(WriteReceipt { value, revision })
331 }
332
333 pub(crate) fn mutate_meta<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<T>) -> Result<WriteReceipt<T>> {
336 let mut guard = self.engine.writer.lock();
337 let writer = guard.as_mut().ok_or(Error::Closed)?;
338 let tx = writer.conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
339 let value = f(&tx)?;
340 let revision = current_revision(&tx)?;
341 tx.commit()?;
342 Ok(WriteReceipt { value, revision })
343 }
344
345 fn invalidate_readiness(&self, conn: &Connection) {
348 let Ok(mut stmt) = conn.prepare("SELECT DISTINCT s.text FROM index_updates u
349 JOIN records r ON r.id=u.record_id JOIN strings s ON s.id=r.namespace_id") else {
350 return;
351 };
352 let Ok(namespaces) = stmt.query_map([], |r| r.get::<_, String>(0)) else {
353 return;
354 };
355 let namespaces: Vec<String> = namespaces.flatten().collect();
356 if namespaces.is_empty() { return; }
357 let mut guard = self.engine.vector_writer.lock();
358 let Some(vector_writer) = guard.as_mut() else { return; };
359 for namespace in namespaces {
360 let _ = crate::embeddings::clear_vector_ready(&vector_writer.conn, &namespace);
361 }
362 }
363
364 pub fn memories(&self) -> crate::memory::MemoryStore { crate::memory::MemoryStore(self.clone()) }
365 pub fn graph(&self) -> crate::graph::GraphStore { crate::graph::GraphStore(self.clone()) }
366 pub fn notes(&self) -> crate::notes::NoteStore { crate::notes::NoteStore(self.clone()) }
367 pub fn embeddings(&self) -> crate::embeddings::EmbeddingStore { crate::embeddings::EmbeddingStore(self.clone()) }
368
369 pub(crate) fn write<T>(&self, f: impl FnOnce(&Writer) -> Result<T>) -> Result<T> {
372 let mut guard = self.engine.writer.lock();
373 let value = f(guard.as_mut().ok_or(Error::Closed)?)?;
374 self.engine.vectors.invalidate();
375 Ok(value)
376 }
377
378 pub(crate) fn note_degrade(&self, degrade: Degrade) {
380 let mut observed = self.engine.degraded.lock();
381 if !observed.contains(°rade) {
382 observed.push(degrade);
383 if observed.len() > 8 { observed.remove(0); }
384 }
385 }
386
387 pub fn update_index(&self) -> Result<HealthReport> {
391 self.catch_up_index(true)?;
392 if let Some(vectorizer) = self.engine.vectorizer.get() { vectorizer.notify_work(); }
393 self.health()
394 }
395
396 pub(crate) fn catch_up_index(&self, blocking: bool) -> Result<()> {
401 if blocking {
402 let mut guard = self.engine.writer.lock();
403 let Some(writer) = guard.as_mut() else { return Ok(()) };
404 return self.index()?.sync(&writer.conn);
405 }
406 let Some(mut guard) = self.engine.writer.try_lock() else { return Ok(()) };
407 let Some(writer) = guard.as_mut() else { return Ok(()) };
408 self.index()?.sync(&writer.conn)
409 }
410
411 pub fn register_event_sink<F: Fn(&crate::events::LogEvent) + Send + Sync + 'static>(&self, sink: F) {
415 self.engine.events.set(Arc::new(sink));
416 }
417
418 pub fn unregister_event_sink(&self) -> bool { self.engine.events.clear() }
420
421 pub fn event_sink_registered(&self) -> bool { self.engine.events.is_registered() }
423
424 pub fn rebuild_indexes(&self) -> Result<HealthReport> {
425 let sink = self.engine.events.get();
426 let started = std::time::Instant::now();
427 {
428 let mut guard = self.engine.writer.lock();
429 let writer = guard.as_mut().ok_or(Error::Closed)?;
430 self.index()?.rebuild(&writer.conn)?;
431 self.engine.vectors.invalidate();
432 }
433 let ms = started.elapsed().as_millis() as u64;
434 let report = self.health()?;
435 if let Some(sink) = sink {
436 let mut event = crate::events::LogEvent::new("index_rebuild");
437 event.ms = ms;
438 event.documents = Some(report.index_document_count);
439 event.format = Some(crate::index::FORMAT.to_string());
440 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| sink(&event)));
441 }
442 Ok(report)
443 }
444
445 pub fn rebuild_progress(&self) -> Result<RebuildProgressReport> {
447 Ok(self.index()?.rebuild_progress())
448 }
449
450 pub fn health(&self) -> Result<HealthReport> {
451 let state = self.read()?;
452 let conn = state.conn();
453 let mut counts = BTreeMap::new();
454 let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM records GROUP BY kind")?;
455 for row in stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))? {
456 let (code, count) = row?;
457 let name = RecordKind::from_code(code).map(|k| k.as_str().to_string()).unwrap_or_else(|| code.to_string());
458 counts.insert(name, count as usize);
459 }
460 let record_count = counts.values().sum();
461 let mut foreign = conn.prepare("PRAGMA foreign_key_check")?;
462 let mut foreign_key_errors = 0;
463 let mut rows = foreign.query([])?;
464 while rows.next()?.is_some() { foreign_key_errors += 1; }
465 Ok(HealthReport {
466 schema_version: schema::SCHEMA_VERSION,
467 revision: current_revision(conn)?,
468 indexed_revision: meta(conn, "indexed_revision")?, record_count,
469 index_document_count: self.index()?.document_count(),
470 pending_index_updates: conn.query_row("SELECT COUNT(*) FROM index_updates", [], |r| r.get::<_, i64>(0))? as usize,
471 sqlite_integrity: conn.query_row("PRAGMA quick_check", [], |r| r.get(0))?,
472 foreign_key_errors, counts,
473 embedder_spaces: self.engine.embedders.space_ids(),
474 reranker_registered: self.engine.rerankers.is_registered(),
475 last_degraded: self.engine.degraded.lock().clone(),
476 })
477 }
478
479 pub fn backup(&self, target: impl AsRef<Path>) -> Result<()> {
481 let target = target.as_ref();
482 let state = self.read()?;
483 let reservation = OpenOptions::new().write(true).create_new(true).open(target)?;
484 drop(reservation);
485 if let Err(err) = state.conn().backup(rusqlite::MAIN_DB, target, None) {
486 let _ = std::fs::remove_file(target);
487 return Err(err.into());
488 }
489 let vectors_target = target.with_file_name(format!("{}.vectors", target.file_name().unwrap().to_string_lossy()));
491 if let Err(err) = state.conn().backup("vectors", &vectors_target, None) {
492 let _ = std::fs::remove_file(&vectors_target);
493 return Err(err.into());
494 }
495 Ok(())
496 }
497
498 pub fn restore(snapshot: impl AsRef<Path>, directory: impl AsRef<Path>) -> Result<Self> {
500 let snapshot = snapshot.as_ref();
501 let source = Connection::open_with_flags(snapshot, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
502 let app: i64 = source.pragma_query_value(None, "application_id", |r| r.get(0))?;
503 let version: i64 = source.pragma_query_value(None, "user_version", |r| r.get(0))?;
504 if app != schema::APPLICATION_ID { return Err(Error::Validation("snapshot is not a p-memory database".into())); }
505 if version != schema::SCHEMA_VERSION { return Err(Error::SchemaVersion { found: version, supported: schema::SCHEMA_VERSION }); }
506 std::fs::create_dir(directory.as_ref())?;
507 source.backup(rusqlite::MAIN_DB, directory.as_ref().join("store.sqlite3"), None)?;
508 let vectors_snapshot = snapshot.with_file_name(format!("{}.vectors", snapshot.file_name().unwrap().to_string_lossy()));
510 if vectors_snapshot.exists() {
511 let vectors_source = Connection::open_with_flags(&vectors_snapshot, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
512 vectors_source.backup(rusqlite::MAIN_DB, directory.as_ref().join("vectors.sqlite3"), None)?;
513 }
514 Self::open(directory)
515 }
516}
517
518pub(crate) fn now_us() -> i64 { chrono::Utc::now().timestamp_micros() }
519pub(crate) fn meta(conn: &Connection, key: &str) -> Result<i64> {
520 Ok(conn.query_row("SELECT value FROM meta WHERE key=?1", [key], |r| r.get(0))?)
521}
522
523pub(crate) fn meta_opt(conn: &Connection, key: &str) -> Result<Option<i64>> {
525 Ok(conn.query_row("SELECT value FROM meta WHERE key=?1", [key], |r| r.get(0)).optional()?)
526}
527
528pub(crate) fn set_meta(conn: &Connection, key: &str, value: i64) -> Result<()> {
530 conn.execute("INSERT INTO meta(key,value) VALUES (?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value", params![key, value])?;
531 Ok(())
532}
533
534pub(crate) fn clear_meta(conn: &Connection, key: &str) -> Result<()> {
536 conn.execute("DELETE FROM meta WHERE key=?1", [key])?;
537 Ok(())
538}
539pub(crate) fn current_revision(conn: &Connection) -> Result<i64> { meta(conn, "revision") }
540pub(crate) fn next_revision(conn: &Connection, record_id: i64) -> Result<i64> {
541 conn.execute("UPDATE meta SET value=value+1 WHERE key='revision'", [])?;
542 let revision = current_revision(conn)?;
543 conn.execute("INSERT INTO index_updates(revision,record_id) VALUES (?1,?2)", params![revision, record_id])?;
544 Ok(revision)
545}
546
547pub(crate) fn term_id(conn: &Connection, text_value: &str) -> Result<i64> {
549 let normalized = text::normalized_tag(text_value);
550 conn.execute("INSERT OR IGNORE INTO strings(text) VALUES (?1)", [&normalized])?;
551 Ok(conn.query_row("SELECT id FROM strings WHERE text=?1", [&normalized], |r| r.get(0))?)
552}
553
554pub(crate) fn term_text(conn: &Connection, id: i64) -> Result<String> {
555 Ok(conn.query_row("SELECT text FROM strings WHERE id=?1", [id], |r| r.get(0))?)
556}
557
558pub(crate) fn record_namespaces(conn: &Connection) -> Result<Vec<String>> {
561 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")?;
562 let mut namespaces = Vec::new();
563 for row in stmt.query_map([], |r| r.get::<_, String>(0))? { namespaces.push(row?); }
564 Ok(namespaces)
565}
566
567pub(crate) fn validate_identity(label: &str, value: &str) -> Result<()> {
568 if value.trim().is_empty() || value != value.trim() || value.chars().any(char::is_control) {
569 return Err(Error::Validation(format!("{label} must be nonempty, trimmed, and contain no control characters")));
570 }
571 Ok(())
572}
573pub(crate) fn validate_filter(filter: &ReadFilter) -> Result<()> {
574 validate_identity("namespace", &filter.namespace)?;
575 if filter.scopes.is_empty() { return Err(Error::Validation("at least one explicit read scope is required".into())); }
576 for scope in &filter.scopes { validate_identity("scope", scope)?; }
577 Ok(())
578}
579pub(crate) fn validate_limit(limit: usize) -> Result<()> {
580 if !(1..=10_000).contains(&limit) { return Err(Error::Validation("limit must be between 1 and 10000".into())); }
581 Ok(())
582}
583
584pub(crate) fn normalize_tags(tags: &[String]) -> Vec<String> {
586 tags.iter().map(|label| text::normalized_tag(label)).filter(|tag| !tag.is_empty()).collect::<BTreeSet<_>>().into_iter().collect()
587}
588
589pub(crate) fn tags_prefix(kind: RecordKind, tags: &[String], exclude: &[String], payload: &Value) -> String {
594 let carries = match kind {
595 RecordKind::Note => false,
596 RecordKind::Chunk => payload.get("ordinal").and_then(Value::as_u64) == Some(0),
597 _ => true,
598 };
599 if !carries { return String::new(); }
600 tags.iter().filter(|tag| !exclude.contains(tag)).cloned().collect::<Vec<_>>().join(" ")
601}
602
603pub(crate) fn split_note_path(relative: &str) -> (Vec<String>, String) {
606 let segments: Vec<&str> = relative.split('/').filter(|segment| !segment.is_empty()).collect();
607 let Some((last, dirs)) = segments.split_last() else { return (Vec::new(), String::new()); };
608 let stem = last.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(last).trim();
609 (dirs.iter().map(|segment| segment.to_string()).collect(), stem.to_string())
610}
611
612pub(crate) fn note_path_parts(conn: &Connection, note_id: i64) -> (Vec<String>, String) {
616 let Ok((path, name)) = conn.query_row("SELECT path,name FROM notes WHERE record_id=?1", [note_id],
617 |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) else {
618 return (Vec::new(), String::new());
619 };
620 if Path::new(&path).is_absolute() { return (Vec::new(), name); }
621 (split_note_path(&path).0, name)
622}
623
624pub(crate) fn index_columns(conn: &Connection, kind: RecordKind, payload: &Value) -> (String, String, Vec<String>) {
629 if kind == RecordKind::Chunk {
630 if payload.get("ordinal").and_then(Value::as_u64).unwrap_or(0) != 0 { return (String::new(), String::new(), Vec::new()); }
632 let note_id = payload.get("note_id").and_then(Value::as_i64).unwrap_or(0);
633 let (dirs, stem) = note_path_parts(conn, note_id);
634 let mut exclude = dirs.clone();
635 if !stem.is_empty() { exclude.push(stem.clone()); }
636 return (stem, dirs.join(" "), exclude);
637 }
638 (record_name(kind, payload), String::new(), Vec::new())
639}
640
641pub(crate) fn put_record(conn: &Connection, kind: RecordKind, input: &RecordInput,
642 payload: &Value, text: &str) -> Result<(RecordHeader, crate::index::IndexDocument)> {
643 validate_identity("namespace", &input.namespace)?;
644 validate_identity("scope", &input.scope)?;
645 for evidence in &input.evidence {
646 if evidence.source.trim().is_empty() { return Err(Error::Validation("evidence source is required".into())); }
647 match (evidence.offset, evidence.limit) {
648 (None, None) => {},
649 (Some(offset), Some(limit)) if offset >= 1 && limit >= 1 => {},
650 _ => return Err(Error::Validation("evidence offset/limit must be a 1-based start and a positive line count".into())),
651 }
652 }
653 let namespace_id = term_id(conn, &input.namespace)?;
654 touch_namespace(&input.namespace);
656 let scope_id = term_id(conn, &input.scope)?;
657 let existing = match input.id {
658 Some(id) => Some(conn.query_row("SELECT created_at_us,updated_at_us,revision,scope_id FROM records WHERE id=?1", [id],
659 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, i64>(3)?))).optional()?
660 .ok_or_else(|| Error::NotFound(id.to_string()))?),
661 None => None,
662 };
663 if let (Some(id), true) = (input.id, existing.as_ref().is_some_and(|v| v.3 != scope_id)) {
664 let blocking: i64 = conn.query_row(
667 "SELECT (SELECT COUNT(*) FROM relations WHERE subject_id=?1 OR object_id=?1) \
668 + (SELECT COUNT(*) FROM event_participants WHERE entity_id=?1)", [id], |r| r.get(0))?;
669 if blocking > 0 {
670 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")));
671 }
672 }
673 if let Some(expected) = input.expected_revision {
674 if existing.as_ref().map(|v| v.2) != Some(expected) { return Err(Error::StaleRevision(input.id.map(|v| v.to_string()).unwrap_or_default())); }
675 }
676 let now = now_us();
677 let created = existing.as_ref().map(|v| v.0).unwrap_or(input.created_at_us.unwrap_or(now));
678 let updated = input.updated_at_us.unwrap_or_else(|| now.max(existing.as_ref().map(|v| v.1).unwrap_or(created)));
679 if updated < created { return Err(Error::Validation("updated_at_us precedes created_at_us".into())); }
680 let tags = normalize_tags(&input.tags);
682 let fingerprint = record_fingerprint(text, &tags);
683 let metadata_json = serde_json::to_string(&input.metadata)?;
684 let evidence_json = serde_json::to_string(&input.evidence)?;
685 let payload_json = serde_json::to_string(payload)?;
686 let (id, revision) = match input.id {
687 Some(id) => {
688 let revision = next_revision(conn, id)?;
689 conn.execute("UPDATE records SET namespace_id=?2,kind=?3,scope_id=?4,updated_at_us=?5,revision=?6,metadata_json=?7,
690 evidence_json=?8,fingerprint=?9,payload_json=?10 WHERE id=?1",
691 params![id, namespace_id, kind.code(), scope_id, updated, revision, metadata_json, evidence_json,
692 fingerprint, payload_json])?;
693 conn.execute("DELETE FROM vectors.embeddings WHERE record_id=?1 AND fingerprint<>?2", params![id, fingerprint])?;
695 (id, revision)
696 }
697 None => {
698 conn.execute("INSERT INTO records(namespace_id,kind,scope_id,created_at_us,updated_at_us,revision,metadata_json,evidence_json,
699 fingerprint,payload_json) VALUES (?1,?2,?3,?4,?5,0,?6,?7,?8,?9)",
700 params![namespace_id, kind.code(), scope_id, created, updated, metadata_json, evidence_json,
701 fingerprint, payload_json])?;
702 let id = conn.last_insert_rowid();
703 let revision = next_revision(conn, id)?;
704 conn.execute("UPDATE records SET revision=?2 WHERE id=?1", params![id, revision])?;
705 (id, revision)
706 }
707 };
708 let tag_ids = set_record_tags(conn, id, &tags)?;
709 let (name, path, exclude) = index_columns(conn, kind, payload);
712 let document = crate::index::IndexDocument { id, namespace_id, scope_id, kind,
713 text: text.to_string(), name, path,
714 note_id: if kind == RecordKind::Chunk { payload.get("note_id").and_then(Value::as_i64).unwrap_or(0) } else { 0 },
715 tags_prefix: tags_prefix(kind, &tags, &exclude, payload), tag_ids };
716 Ok((RecordHeader { id, namespace: input.namespace.clone(), kind, scope: input.scope.clone(),
717 created_at_us: created, updated_at_us: updated, revision, tags,
718 evidence: input.evidence.clone(), metadata: input.metadata.clone() }, document))
719}
720
721pub(crate) fn record_fingerprint(text: &str, tags: &[String]) -> String {
723 text::digest(&format!("text-v1\n{text}\n{}", tags.join(" ")))
724}
725
726pub(crate) fn set_record_tags(conn: &Connection, id: i64, tags: &[String]) -> Result<Vec<i64>> {
728 conn.execute("DELETE FROM record_tags WHERE record_id=?1", [id])?;
729 let mut tag_ids = Vec::with_capacity(tags.len());
730 for tag in tags {
731 let tag_id = term_id(conn, tag)?;
732 conn.execute("INSERT OR IGNORE INTO record_tags(record_id,tag_id) VALUES (?1,?2)", params![id, tag_id])?;
733 tag_ids.push(tag_id);
734 }
735 Ok(tag_ids)
736}
737
738pub(crate) fn record_tag_pairs(conn: &Connection, id: i64) -> Result<Vec<(i64, String)>> {
740 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")?;
741 let mut pairs = Vec::new();
742 for row in stmt.query_map([id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? { pairs.push(row?); }
743 Ok(pairs)
744}
745
746pub(crate) fn index_document(conn: &Connection, id: i64, kind: RecordKind, text: String) -> Result<crate::index::IndexDocument> {
748 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",
749 [id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
750 let payload: Value = serde_json::from_str(&payload_json)?;
751 let pairs = record_tag_pairs(conn, id)?;
752 let tags: Vec<String> = pairs.iter().map(|(_, tag)| tag.clone()).collect();
753 let (name, path, exclude) = index_columns(conn, kind, &payload);
754 Ok(crate::index::IndexDocument { id, namespace_id, scope_id, kind, text,
755 name, path,
756 note_id: if kind == RecordKind::Chunk { payload.get("note_id").and_then(Value::as_i64).unwrap_or(0) } else { 0 },
757 tags_prefix: tags_prefix(kind, &tags, &exclude, &payload),
758 tag_ids: pairs.into_iter().map(|(tag_id, _)| tag_id).collect() })
759}
760
761pub(crate) fn chunk_notes(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, (i64, usize)>> {
765 let mut out = BTreeMap::new();
766 if ids.is_empty() { return Ok(out); }
767 let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
768 let mut stmt = conn.prepare(&format!("SELECT record_id,note_id,\"offset\" FROM chunks WHERE record_id IN ({placeholders})"))?;
769 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)?)))? {
770 let (id, note_id, offset) = row?;
771 out.insert(id, (note_id, offset.max(0) as usize));
772 }
773 Ok(out)
774}
775
776pub(crate) fn namespace_root(conn: &Connection, namespace_id: i64) -> Result<Option<String>> {
778 Ok(conn.query_row("SELECT root FROM namespace_roots WHERE namespace_id=?1", [namespace_id], |r| r.get(0)).optional()?)
779}
780
781pub(crate) fn absolute_note_path(conn: &Connection, namespace_id: i64, stored: &str) -> String {
783 match namespace_root(conn, namespace_id) {
784 Ok(Some(root)) => Path::new(&root).join(stored.replace('/', std::path::MAIN_SEPARATOR_STR)).to_string_lossy().into_owned(),
785 _ => stored.to_string(),
786 }
787}
788
789pub(crate) fn record_value(conn: &Connection, key: &RecordKey) -> Result<Option<Value>> {
790 Ok(record_values(conn, &[key.id])?.remove(&key.id))
791}
792
793pub(crate) fn record_values(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, Value>> {
796 let mut out = BTreeMap::new();
797 if ids.is_empty() { return Ok(out); }
798 let placeholders = vec!["?"; ids.len()].join(",");
799 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
800 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,
801 r.metadata_json,r.evidence_json,r.payload_json FROM records r
802 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"))?;
803 let mut rows: Vec<(i64, i64, String, i64, String, i64, i64, i64, String, String, String)> = Vec::new();
804 for row in stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?,
805 r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, String>(4)?, r.get::<_, i64>(5)?, r.get::<_, i64>(6)?,
806 r.get::<_, i64>(7)?, r.get::<_, String>(8)?, r.get::<_, String>(9)?, r.get::<_, String>(10)?)))? {
807 rows.push(row?);
808 }
809 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 \
810 WHERE rt.record_id IN ({placeholders}) ORDER BY rt.record_id,t.text"))?;
811 let mut tags: BTreeMap<i64, Vec<String>> = BTreeMap::new();
812 for row in tags_stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
813 let (id, tag) = row?;
814 tags.entry(id).or_default().push(tag);
815 }
816 let mut note_meta: BTreeMap<i64, (String, String)> = BTreeMap::new();
819 {
820 let mut stmt = conn.prepare(&format!("SELECT n.record_id,n.path,n.name FROM notes n \
821 WHERE n.record_id IN ({placeholders})"))?;
822 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)?)))? {
823 let (id, path, name) = row?;
824 note_meta.insert(id, (path, name));
825 }
826 }
827 for (id, namespace_id, namespace, kind_code, scope, created, updated, revision, metadata, evidence, payload) in rows {
828 let kind = RecordKind::from_code(kind_code).ok_or_else(|| Error::Validation("invalid stored record kind".into()))?;
829 let header = RecordHeader { id, namespace, kind, scope,
830 created_at_us: created, updated_at_us: updated, revision, tags: tags.remove(&id).unwrap_or_default(),
831 metadata: serde_json::from_str(&metadata)?, evidence: serde_json::from_str(&evidence)? };
832 let mut value = serde_json::to_value(header)?;
833 let object = value.as_object_mut().ok_or_else(|| Error::Validation("invalid stored header".into()))?;
834 let mut payload: Metadata = serde_json::from_str(&payload)?;
835 if let Some(type_id) = payload.get("memory_type_id").and_then(Value::as_i64) {
837 payload.insert("memory_type".into(), Value::String(term_text(conn, type_id)?));
838 payload.remove("memory_type_id");
839 }
840 if kind == RecordKind::Note {
841 let (stored, name) = note_meta.remove(&id).unwrap_or_default();
843 let source = absolute_note_path(conn, namespace_id, &stored);
844 payload.insert("source".into(), Value::String(source));
845 payload.insert("title".into(), Value::String(name));
846 }
847 object.extend(payload);
848 out.insert(id, value);
849 }
850 Ok(out)
851}
852
853pub(crate) fn matches_filter(conn: &Connection, key: &RecordKey, filter: &ReadFilter) -> Result<bool> {
854 validate_filter(filter)?;
855 let row: Option<(i64, i64)> = conn.query_row("SELECT namespace_id,scope_id FROM records WHERE id=?1", [key.id],
856 |r| Ok((r.get(0)?, r.get(1)?))).optional()?;
857 let Some((namespace_id, scope_id)) = row else { return Ok(false) };
858 if term_text(conn, namespace_id)? != text::normalized_tag(&filter.namespace) { return Ok(false); }
859 let scope = term_text(conn, scope_id)?;
860 if !filter.scopes.iter().any(|s| text::normalized_tag(s) == scope) { return Ok(false); }
861 for tag in &filter.tags {
862 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)",
863 params![key.id, text::normalized_tag(tag)], |r| r.get(0))?;
864 if !exists { return Ok(false); }
865 }
866 Ok(true)
867}
868
869pub(crate) fn get<T: DeserializeOwned>(conn: &Connection, key: &RecordKey, filter: &ReadFilter) -> Result<T> {
870 if !matches_filter(conn, key, filter)? { return Err(Error::NotFound(key.id.to_string())); }
871 serde_json::from_value(record_value(conn, key)?.ok_or_else(|| Error::NotFound(key.id.to_string()))?).map_err(Error::from)
872}
873
874pub(crate) fn load_many<T: DeserializeOwned>(conn: &Connection, ids: &[i64], filter: &ReadFilter) -> Result<BTreeMap<i64, T>> {
877 let mut out = BTreeMap::new();
878 if ids.is_empty() { return Ok(out); }
879 validate_filter(filter)?;
880 let (condition, values) = filter_sql(filter, &[], true)?;
881 let placeholders = vec!["?"; ids.len()].join(",");
882 let mut stmt = conn.prepare(&format!("SELECT r.id FROM records r WHERE r.id IN ({placeholders}) AND {condition} ORDER BY r.id"))?;
883 let params = ids.iter().map(|id| SqlValue::Integer(*id)).chain(values).collect::<Vec<_>>();
884 let allowed = stmt.query_map(params_from_iter(params), |r| r.get::<_, i64>(0))?.collect::<std::result::Result<Vec<_>, _>>()?;
885 for (id, value) in record_values(conn, &allowed)? {
886 out.insert(id, serde_json::from_value(value)?);
887 }
888 Ok(out)
889}
890
891pub(crate) fn filter_sql(filter: &ReadFilter, kinds: &[RecordKind], by_ids: bool) -> Result<(String, Vec<SqlValue>)> {
899 validate_filter(filter)?;
900 let mut query = if by_ids {
901 "+r.namespace_id=(SELECT id FROM strings WHERE text=?)".to_string()
902 } else {
903 "r.namespace_id=(SELECT id FROM strings WHERE text=?)".to_string()
904 };
905 let mut values = vec![SqlValue::Text(text::normalized_tag(&filter.namespace))];
906 query.push_str(" AND r.scope_id IN (SELECT id FROM strings WHERE text IN (");
907 query.push_str(&vec!["?"; filter.scopes.len()].join(",")); query.push_str("))");
908 values.extend(filter.scopes.iter().map(|s| SqlValue::Text(text::normalized_tag(s))));
909 if !kinds.is_empty() {
910 query.push_str(" AND r.kind IN ("); query.push_str(&vec!["?"; kinds.len()].join(",")); query.push(')');
911 values.extend(kinds.iter().map(|k| SqlValue::Integer(k.code())));
912 }
913 for tag in &filter.tags {
914 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=?)");
915 values.push(SqlValue::Text(text::normalized_tag(tag)));
916 }
917 Ok((query, values))
918}
919
920pub(crate) fn select_ids(conn: &Connection, filter: &ReadFilter, kinds: &[RecordKind]) -> Result<Vec<i64>> {
923 let (condition, values) = filter_sql(filter, kinds, false)?;
924 let mut stmt = conn.prepare(&format!("SELECT r.id FROM records r WHERE {condition} ORDER BY r.id"))?;
925 let ids = stmt.query_map(params_from_iter(values), |r| r.get::<_, i64>(0))?
926 .collect::<std::result::Result<Vec<_>, _>>()?;
927 Ok(ids)
928}
929
930pub(crate) fn count_matches(conn: &Connection, filter: &ReadFilter, kinds: &[RecordKind]) -> Result<usize> {
932 let (condition, values) = filter_sql(filter, kinds, false)?;
933 let count: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM records r WHERE {condition}"), params_from_iter(values), |r| r.get(0))?;
934 Ok(count as usize)
935}
936
937pub(crate) fn record_text(kind: RecordKind, payload: &Value) -> String {
941 let field = |key: &str| payload.get(key).and_then(Value::as_str).unwrap_or("").to_string();
942 match kind {
943 RecordKind::Memory => field("judgment"),
944 RecordKind::Entity => entity_body(payload),
945 RecordKind::Relation => format!("{} {} {} {}", field("subject_name"), field("predicate"), field("object_name"), field("reason")),
946 RecordKind::Event => format!("{} {} {} {}", field("name"), field("summary"), name_list(payload), field("reason")),
947 RecordKind::Note | RecordKind::Chunk => String::new(),
949 }
950}
951
952pub(crate) fn record_name(kind: RecordKind, payload: &Value) -> String {
954 match kind {
955 RecordKind::Entity => payload.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
956 _ => String::new(),
957 }
958}
959
960pub(crate) fn event_text_lengths(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, usize>> {
967 let mut out = BTreeMap::new();
968 if ids.is_empty() { return Ok(out); }
969 let placeholders = vec!["?"; ids.len()].join(",");
970 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
971 let field = |name: &str| format!(
972 "CASE WHEN json_type(r.payload_json,'$.{name}')='text' THEN json_extract(r.payload_json,'$.{name}') ELSE '' END");
973 let names = "COALESCE(CASE WHEN json_type(r.payload_json,'$.participant_names')='array' \
976 THEN (SELECT group_concat(j.value,' ') FROM json_each(r.payload_json,'$.participant_names') j \
977 WHERE j.type='text') ELSE '' END,'')";
978 let mut stmt = conn.prepare(&format!(
979 "SELECT r.id, LENGTH({} || ' ' || {} || ' ' || {names} || ' ' || {}) \
980 FROM records r WHERE r.id IN ({placeholders}) ORDER BY r.id",
981 field("name"), field("summary"), field("reason")))?;
982 for row in stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))? {
983 let (id, length) = row?;
984 out.insert(id, length.max(0) as usize);
985 }
986 Ok(out)
987}
988
989pub(crate) fn entity_names(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, String>> {
992 let mut out = BTreeMap::new();
993 if ids.is_empty() { return Ok(out); }
994 let placeholders = vec!["?"; ids.len()].join(",");
995 let mut stmt = conn.prepare(&format!("SELECT record_id,name FROM entities WHERE record_id IN ({placeholders})"))?;
996 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
997 for row in stmt.query_map(params_from_iter(params), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
998 let (id, name) = row?;
999 out.insert(id, name);
1000 }
1001 Ok(out)
1002}
1003
1004fn name_list(payload: &Value) -> String {
1005 payload.get("participant_names").and_then(Value::as_array)
1006 .map(|names| names.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "))
1007 .unwrap_or_default()
1008}
1009
1010fn entity_body(payload: &Value) -> String {
1011 let aliases = payload.get("aliases").and_then(Value::as_array)
1012 .map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" ")).unwrap_or_default();
1013 let summary = payload.get("summary").and_then(Value::as_str).unwrap_or("");
1014 let attr_text = payload.get("attributes").and_then(Value::as_object).map(|attrs| {
1015 attrs.iter().map(|(key, values)| {
1016 let joined = values.as_array().map(|v| v.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" ")).unwrap_or_default();
1017 format!("{key} {joined}")
1018 }).collect::<Vec<_>>().join(" ")
1019 }).unwrap_or_default();
1020 format!("{aliases} {summary} {attr_text}")
1021}
1022
1023pub(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)?;
1024 if let Some(cursor) = after {
1025 let id: i64 = cursor.parse().map_err(|_| Error::Validation("invalid page cursor".into()))?;
1026 condition.push_str(" AND r.id>?");
1027 values.push(SqlValue::Integer(id));
1028 }
1029 values.push(SqlValue::Integer(limit.min(i64::MAX as usize) as i64));
1030 let mut stmt = conn.prepare(&format!("SELECT r.id FROM records r WHERE {condition} ORDER BY r.id LIMIT ?"))?;
1031 let rows = stmt.query_map(params_from_iter(values), |r| r.get::<_, i64>(0))?;
1032 let mut keys = Vec::new();
1033 for row in rows { keys.push(RecordKey { id: row? }); }
1034 Ok(keys)
1035}
1036
1037pub(crate) fn list<T: DeserializeOwned>(conn: &Connection, kind: RecordKind, request: &PageRequest) -> Result<Page<T>> {
1038 validate_limit(request.limit)?;
1039 let mut keys = select_keys(conn, &request.filter, &[kind], request.limit + 1, request.after.as_deref())?;
1040 let has_more = keys.len() > request.limit;
1041 keys.truncate(request.limit);
1042 let next_cursor = if has_more { keys.last().map(RecordKey::index_key) } else { None };
1043 let ids: Vec<i64> = keys.iter().map(|key| key.id).collect();
1045 let mut loaded: BTreeMap<i64, T> = load_many(conn, &ids, &request.filter)?;
1046 let items = keys.into_iter().filter_map(|key| loaded.remove(&key.id)).collect::<Vec<_>>();
1047 Ok(Page { items, next_cursor })
1048}
1049
1050pub(crate) fn delete_record(conn: &Connection, key: &RecordKey) -> Result<bool> {
1051 let namespace = namespace_of(conn, key.id)?;
1054 let changed = conn.execute("DELETE FROM records WHERE id=?1", [key.id]);
1055 let changed = match changed {
1056 Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation =>
1057 return Err(Error::Conflict(format!("record {} is still referenced", key.id))),
1058 other => other?,
1059 };
1060 if changed > 0 {
1061 conn.execute("DELETE FROM vectors.embeddings WHERE record_id=?1", [key.id])?;
1063 next_revision(conn, key.id)?;
1064 if let Some(namespace) = namespace { touch_namespace(&namespace); }
1065 }
1066 Ok(changed > 0)
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072
1073 fn batched_load_plan(conn: &Connection, ids: &[i64], by_ids: bool) -> String {
1075 let (condition, values) = filter_sql(&ReadFilter::default(), &[], by_ids).unwrap();
1076 let placeholders = vec!["?"; ids.len()].join(",");
1077 let sql = format!("EXPLAIN QUERY PLAN SELECT r.id FROM records r WHERE r.id IN ({placeholders}) AND {condition} ORDER BY r.id");
1078 let params: Vec<SqlValue> = ids.iter().map(|id| SqlValue::Integer(*id)).chain(values).collect();
1079 let mut stmt = conn.prepare(&sql).unwrap();
1080 let plans: Vec<String> = stmt.query_map(params_from_iter(params), |row| row.get::<_, String>(3))
1081 .unwrap().map(|row| row.unwrap()).collect();
1082 plans.join(" | ")
1083 }
1084
1085 fn seed(kb: &KnowledgeBase, rows: i64) {
1086 let inputs: Vec<crate::MemoryInput> = (1..=rows).map(|i| crate::MemoryInput::new(format!("记录 {i}"))).collect();
1087 kb.memories().upsert_many(&inputs).unwrap();
1088 }
1089
1090 fn sample_ids() -> Vec<i64> { (1..=10).collect() }
1091
1092 #[test]
1095 fn batched_load_stays_on_the_primary_key() {
1096 let dir = tempfile::tempdir().unwrap();
1097 let kb = KnowledgeBase::open(dir.path()).unwrap();
1098 seed(&kb, 100);
1099 let guard = kb.read().unwrap();
1100 let plan = batched_load_plan(guard.conn(), &sample_ids(), true);
1101 assert!(plan.contains("INTEGER PRIMARY KEY"), "批量取回退化为扫索引:{plan}");
1102 }
1103
1104 #[test]
1107 fn event_text_lengths_match_record_text() {
1108 let dir = tempfile::tempdir().unwrap();
1109 let kb = KnowledgeBase::open(dir.path()).unwrap();
1110 let entity = |name: &str| crate::EntityInput { record: Default::default(), name: name.into(),
1111 entity_type: "person".into(), aliases: vec![], attributes: BTreeMap::new(), summary: String::new() };
1112 let created = kb.graph().apply_batch(&crate::GraphBatch {
1113 entities: vec![entity("甲"), entity("乙")], ..Default::default()
1114 }).unwrap().value;
1115 let (first, second) = (created.entities[0].header.id, created.entities[1].header.id);
1116 let created = kb.graph().apply_batch(&crate::GraphBatch {
1117 events: vec![
1118 crate::EventInput { record: Default::default(), name: "别鹤典仪".into(), summary: "两人同去".into(),
1119 participants: vec![first, second], confidence: 1.0, reason: "有人证".into() },
1120 crate::EventInput { record: Default::default(), name: "堂中自语".into(), summary: String::new(),
1121 participants: vec![first], confidence: 1.0, reason: String::new() },
1122 ], ..Default::default()
1123 }).unwrap().value;
1124 let ids: Vec<i64> = created.events.iter().map(|event| event.header.id).collect();
1125
1126 {
1128 let raw = Connection::open(dir.path().join("store.sqlite3")).unwrap();
1129 let payloads = [
1130 r#"{"name":7,"summary":"只剩数字名","participant_names":"甲 乙","reason":null}"#,
1131 r#"{"name":"正常","summary":null,"participant_names":["甲",7,"乙"],"reason":"理由"}"#,
1132 ];
1133 for (id, payload) in ids.iter().zip(payloads) {
1134 raw.execute("UPDATE records SET payload_json=?1 WHERE id=?2", params![payload, id]).unwrap();
1135 }
1136 }
1137
1138 let guard = kb.read().unwrap();
1139 let conn = guard.conn();
1140 let lengths = event_text_lengths(conn, &ids).unwrap();
1141 assert_eq!(lengths.len(), ids.len(), "每条事件都该有长度");
1142 for id in ids {
1143 let payload = record_values(conn, &[id]).unwrap().remove(&id).unwrap();
1144 assert_eq!(lengths[&id], record_text(RecordKind::Event, &payload).chars().count(),
1145 "事件 {id} 的 SQL 长度与 record_text 不一致");
1146 }
1147 }
1148
1149 fn fixture_space() -> crate::embeddings::EmbeddingSpace {
1151 crate::embeddings::EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(),
1152 dimension: 2, text_version: 1, encoding: "f32".into() }
1153 }
1154
1155 fn cache_partition(kb: &KnowledgeBase, space: &crate::embeddings::EmbeddingSpace, namespace: &str) {
1157 kb.partition(space, namespace, "public").unwrap();
1158 }
1159
1160 fn cached_namespaces(kb: &KnowledgeBase) -> BTreeSet<String> {
1162 kb.engine.vectors.entries.lock().keys().map(|(_, namespace, _)| namespace.clone()).collect()
1163 }
1164
1165 fn namespace_filter(namespace: &str) -> ReadFilter {
1166 ReadFilter { namespace: namespace.into(), scopes: vec!["public".into()], tags: vec![], note_ids: vec![] }
1167 }
1168
1169 #[test]
1171 fn invalidating_one_namespace_leaves_the_others_alone() {
1172 let cache = VectorCache::new();
1173 let key = |namespace: &str| ("v".to_string(), namespace.to_string(), "public".to_string());
1174 cache.entries.lock().insert(key("a"), None);
1175 cache.entries.lock().insert(key("b"), None);
1176 let epoch_b = cache.epoch_of("b");
1177
1178 cache.invalidate_namespaces(&HashSet::from(["a".to_string()]));
1179
1180 assert!(cache.entries.lock().get(&key("a")).is_none(), "写过的领域要清掉条目");
1181 assert!(cache.entries.lock().get(&key("b")).is_some(), "没写过的领域不该被牵连");
1182 assert_eq!(cache.epoch_of("b"), epoch_b, "没写过的领域版本号不动");
1183 assert_ne!(cache.epoch_of("a"), epoch_b, "写过的领域版本号要前进,在途载入才会作废");
1184
1185 let epoch_a = cache.epoch_of("a");
1187 cache.invalidate();
1188 assert!(cache.entries.lock().is_empty());
1189 assert!(cache.epoch_of("a") > epoch_a && cache.epoch_of("b") > epoch_b);
1190 }
1191
1192 #[test]
1194 fn writing_one_namespace_keeps_other_vector_partitions_cached() {
1195 let dir = tempfile::tempdir().unwrap();
1196 let kb = KnowledgeBase::open(dir.path()).unwrap();
1197 let space = fixture_space();
1198 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1199 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["a".to_string(), "b".to_string()]));
1200
1201 let mut input = crate::MemoryInput::new("写在 a 领域的一条");
1202 input.record.namespace = "a".into();
1203 kb.memories().upsert(input).unwrap();
1204
1205 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["b".to_string()]), "只该清掉被写的那个领域");
1206 }
1207
1208 #[test]
1210 fn deleting_a_record_evicts_only_its_own_namespace() {
1211 let dir = tempfile::tempdir().unwrap();
1212 let kb = KnowledgeBase::open(dir.path()).unwrap();
1213 let mut input = crate::MemoryInput::new("要被删掉的一条");
1214 input.record.namespace = "a".into();
1215 let id = kb.memories().upsert(input).unwrap().value.header.id;
1216
1217 let space = fixture_space();
1218 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1219 kb.memories().delete(id, &namespace_filter("a")).unwrap();
1220
1221 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["b".to_string()]), "删掉的领域要清,别的领域留着");
1222 }
1223
1224 #[test]
1227 fn filling_vectors_only_evicts_the_namespaces_it_wrote() {
1228 let dir = tempfile::tempdir().unwrap();
1229 let kb = KnowledgeBase::open(dir.path()).unwrap();
1230 let space = fixture_space();
1231 kb.embeddings().register_space(space.clone()).unwrap();
1232 kb.embeddings().register_embedder("v", |texts: &[String]| -> std::result::Result<Vec<Vec<f32>>, crate::EmbedCallbackError> {
1233 Ok(texts.iter().map(|_| vec![1.0f32, 0.0]).collect())
1234 }).unwrap();
1235 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1236 kb.memories().upsert(crate::MemoryInput::new("补齐用的一条")).unwrap();
1237 kb.embeddings().sync("v", 32).unwrap();
1238
1239 let cached = cached_namespaces(&kb);
1240 assert!(cached.contains("a") && cached.contains("b"),
1241 "补齐只写了 default 领域,a 与 b 的分区缓存不该被牵连:{cached:?}");
1242 }
1243
1244 #[test]
1247 fn an_unregistered_write_falls_back_to_invalidating_everything() {
1248 let dir = tempfile::tempdir().unwrap();
1249 let kb = KnowledgeBase::open(dir.path()).unwrap();
1250 let space = fixture_space();
1251 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1252
1253 kb.mutate(|tx| Ok(tx.execute("INSERT INTO meta(key,value) VALUES ('cache_probe',1)
1255 ON CONFLICT(key) DO UPDATE SET value=excluded.value", [])?)).unwrap();
1256
1257 assert!(cached_namespaces(&kb).is_empty(), "登记为空却改过行时必须整体失效");
1258 }
1259}