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
10const SELF_HEAL_ATTEMPTS: usize = 1000;
15
16pub(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 index: RwLock<Option<Arc<TextIndex>>>,
113 pub vectors: VectorCache,
114 pub embedders: crate::embeddings::EmbedderRegistry,
116 pub rerankers: crate::search::RerankerRegistry,
117 pub events: crate::events::EventRegistry,
119 pub vectorizer: std::sync::OnceLock<Arc<crate::embeddings::Vectorizer>>,
121 pub degraded: Mutex<Vec<Degrade>>,
123 pub root: PathBuf,
124}
125
126fn open_reader(root: &Path) -> Result<Connection> {
128 let conn = Connection::open(root.join("store.sqlite3"))?;
129 conn.execute_batch("PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;")?;
130 Ok(conn)
131}
132
133#[derive(Clone)]
135pub struct KnowledgeBase { pub(crate) engine: Arc<Engine> }
136
137pub(crate) struct ReadGuard<'a> { engine: &'a Engine, conn: Option<Connection> }
139
140impl ReadGuard<'_> {
141 pub fn conn(&self) -> &Connection { self.conn.as_ref().expect("read connection lives until drop") }
142}
143
144impl Drop for ReadGuard<'_> {
145 fn drop(&mut self) {
146 let Some(conn) = self.conn.take() else { return };
147 if let Some(readers) = self.engine.readers.lock().as_mut() { readers.idle.push(conn); }
149 }
150}
151
152impl KnowledgeBase {
153 pub fn open(directory: impl AsRef<Path>) -> Result<Self> {
154 std::fs::create_dir_all(directory.as_ref())?;
155 let root = std::fs::canonicalize(directory.as_ref())?;
156 let file_lock = OpenOptions::new().create(true).truncate(false).read(true).write(true).open(root.join("writer.lock"))?;
157 if !file_lock.try_lock_exclusive()? { return Err(Error::Locked(root.display().to_string())); }
158 let mut write_conn = Connection::open(root.join("store.sqlite3"))?;
159 schema::initialize(&mut write_conn)?;
160 let index = Arc::new(TextIndex::open(&root)?);
161 index.recover(&write_conn)?;
162 let reader = open_reader(&root)?;
164 let engine = Arc::new(Engine {
165 writer: Mutex::new(Some(Writer { conn: write_conn, _file_lock: file_lock })),
166 readers: Mutex::new(Some(Readers { idle: vec![reader] })),
167 index: RwLock::new(Some(index)), vectors: VectorCache::new(),
168 embedders: crate::embeddings::EmbedderRegistry::new(),
169 rerankers: crate::search::RerankerRegistry::new(),
170 events: crate::events::EventRegistry::default(),
171 vectorizer: std::sync::OnceLock::new(),
172 degraded: Mutex::new(Vec::new()), root,
173 });
174 engine.vectorizer.set(crate::embeddings::Vectorizer::start(&engine)?).unwrap_or_else(|_| unreachable!("vectorizer starts once"));
176 Ok(Self { engine })
177 }
178
179 pub fn directory(&self) -> &Path { &self.engine.root }
180
181 pub(crate) fn index(&self) -> Result<Arc<TextIndex>> {
183 self.engine.index.read().clone().ok_or(Error::Closed)
184 }
185
186 pub(crate) fn index_documents(&self, docs: &[crate::index::IndexDocument]) -> Result<()> {
189 self.index()?.stage(docs)
190 }
191
192 pub fn close(&self) -> Result<()> {
193 if let Some(vectorizer) = self.engine.vectorizer.get() { vectorizer.stop(); }
195 let mut guard = self.engine.writer.lock();
196 let result = match guard.as_ref() {
197 Some(writer) => self.index()?.sync(&writer.conn),
198 None => Ok(()),
199 };
200 *guard = None;
201 *self.engine.index.write() = None;
203 *self.engine.readers.lock() = None;
204 result
205 }
206
207 pub(crate) fn read(&self) -> Result<ReadGuard<'_>> {
209 let conn = {
210 let mut readers = self.engine.readers.lock();
211 match readers.as_mut() {
212 Some(readers) => match readers.idle.pop() {
213 Some(conn) => conn,
214 None => open_reader(&self.engine.root)?,
215 },
216 None => return Err(Error::Closed),
217 }
218 };
219 Ok(ReadGuard { engine: &self.engine, conn: Some(conn) })
220 }
221
222 pub(crate) fn partition(&self, conn: &Connection, space: &crate::embeddings::EmbeddingSpace,
225 namespace: &str, scope: &str) -> Result<Option<Arc<crate::embeddings::Partition>>> {
226 let key = (space.id.clone(), namespace.to_string(), scope.to_string());
227 let epoch = self.engine.vectors.epoch_of(namespace);
228 let cached = self.engine.vectors.entries.lock().get(&key).cloned();
229 if let Some(partition) = cached { return Ok(partition); }
230 let loaded = crate::embeddings::Partition::load(conn, space, namespace, scope)?.map(Arc::new);
231 {
235 let mut entries = self.engine.vectors.entries.lock();
236 if self.engine.vectors.epoch_of(namespace) == epoch { entries.insert(key, loaded.clone()); }
237 }
238 Ok(loaded)
239 }
240
241 pub(crate) fn sync_index_if_behind(&self, conn: &Connection) -> Result<()> {
247 let pending: i64 = conn.query_row("SELECT COUNT(*) FROM index_updates", [], |r| r.get(0))?;
248 if pending == 0 { return Ok(()); }
249 let filling = self.engine.vectorizer.get().map(|vectorizer| vectorizer.clone());
253 for _ in 0..SELF_HEAL_ATTEMPTS {
254 match self.engine.writer.try_lock() {
255 Some(mut guard) => return match guard.as_mut() {
256 Some(writer) => self.index()?.sync(&writer.conn),
257 None => Ok(()),
258 },
259 None => {
260 let still: i64 = conn.query_row("SELECT COUNT(*) FROM index_updates", [], |r| r.get(0))?;
262 if still == 0 { return Ok(()); }
263 if !filling.as_ref().is_some_and(|vectorizer| vectorizer.is_filling()) { return Ok(()); }
265 std::thread::sleep(std::time::Duration::from_millis(1));
266 }
267 }
268 }
269 Ok(())
270 }
271
272 pub(crate) fn mutate<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<T>) -> Result<WriteReceipt<T>> {
274 let mut guard = self.engine.writer.lock();
275 let writer = guard.as_mut().ok_or(Error::Closed)?;
276 let changed_before = writer.conn.total_changes();
277 let log = TouchLog::install();
278 let tx = writer.conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
279 let value = f(&tx)?;
280 let revision = current_revision(&tx)?;
281 tx.commit()?;
282 let touched = log.take();
283 drop(log);
284 if touched.is_empty() {
288 if writer.conn.total_changes() > changed_before { self.engine.vectors.invalidate(); }
291 } else {
292 self.engine.vectors.invalidate_namespaces(&touched);
293 }
294 self.invalidate_readiness(&writer.conn);
297 if let Some(vectorizer) = self.engine.vectorizer.get() { vectorizer.notify_work(); }
299 Ok(WriteReceipt { value, revision })
300 }
301
302 pub(crate) fn mutate_meta<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 tx = writer.conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
308 let value = f(&tx)?;
309 let revision = current_revision(&tx)?;
310 tx.commit()?;
311 Ok(WriteReceipt { value, revision })
312 }
313
314 fn invalidate_readiness(&self, conn: &Connection) {
317 let Ok(mut stmt) = conn.prepare("SELECT DISTINCT s.text FROM index_updates u
318 JOIN records r ON r.id=u.record_id JOIN strings s ON s.id=r.namespace_id") else { return };
319 let Ok(namespaces) = stmt.query_map([], |r| r.get::<_, String>(0)) else { return };
320 for namespace in namespaces.flatten() {
321 let _ = crate::embeddings::clear_vector_ready(conn, &namespace);
322 }
323 }
324
325 pub fn memories(&self) -> crate::memory::MemoryStore { crate::memory::MemoryStore(self.clone()) }
326 pub fn graph(&self) -> crate::graph::GraphStore { crate::graph::GraphStore(self.clone()) }
327 pub fn notes(&self) -> crate::notes::NoteStore { crate::notes::NoteStore(self.clone()) }
328 pub fn embeddings(&self) -> crate::embeddings::EmbeddingStore { crate::embeddings::EmbeddingStore(self.clone()) }
329
330 pub(crate) fn write<T>(&self, f: impl FnOnce(&Writer) -> Result<T>) -> Result<T> {
333 let mut guard = self.engine.writer.lock();
334 let value = f(guard.as_mut().ok_or(Error::Closed)?)?;
335 self.engine.vectors.invalidate();
336 Ok(value)
337 }
338
339 pub(crate) fn note_degrade(&self, degrade: Degrade) {
341 let mut observed = self.engine.degraded.lock();
342 if !observed.contains(°rade) {
343 observed.push(degrade);
344 if observed.len() > 8 { observed.remove(0); }
345 }
346 }
347
348 pub fn update_index(&self) -> Result<HealthReport> {
352 self.catch_up_index()?;
353 self.health()
354 }
355
356 pub(crate) fn catch_up_index(&self) -> Result<()> {
359 let mut guard = self.engine.writer.lock();
360 let writer = guard.as_mut().ok_or(Error::Closed)?;
361 self.index()?.sync(&writer.conn)
362 }
363
364 pub fn register_event_sink<F: Fn(&crate::events::LogEvent) + Send + Sync + 'static>(&self, sink: F) {
368 self.engine.events.set(Arc::new(sink));
369 }
370
371 pub fn unregister_event_sink(&self) -> bool { self.engine.events.clear() }
373
374 pub fn event_sink_registered(&self) -> bool { self.engine.events.is_registered() }
376
377 pub fn rebuild_indexes(&self) -> Result<HealthReport> {
378 let sink = self.engine.events.get();
379 let started = std::time::Instant::now();
380 {
381 let mut guard = self.engine.writer.lock();
382 let writer = guard.as_mut().ok_or(Error::Closed)?;
383 self.index()?.rebuild(&writer.conn)?;
384 self.engine.vectors.invalidate();
385 }
386 let ms = started.elapsed().as_millis() as u64;
387 let report = self.health()?;
388 if let Some(sink) = sink {
389 let mut event = crate::events::LogEvent::new("index_rebuild");
390 event.ms = ms;
391 event.documents = Some(report.index_document_count);
392 event.format = Some(crate::index::FORMAT.to_string());
393 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| sink(&event)));
394 }
395 Ok(report)
396 }
397
398 pub fn rebuild_progress(&self) -> Result<RebuildProgressReport> {
400 Ok(self.index()?.rebuild_progress())
401 }
402
403 pub fn health(&self) -> Result<HealthReport> {
404 let state = self.read()?;
405 let conn = state.conn();
406 let mut counts = BTreeMap::new();
407 let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM records GROUP BY kind")?;
408 for row in stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))? {
409 let (code, count) = row?;
410 let name = RecordKind::from_code(code).map(|k| k.as_str().to_string()).unwrap_or_else(|| code.to_string());
411 counts.insert(name, count as usize);
412 }
413 let record_count = counts.values().sum();
414 let mut foreign = conn.prepare("PRAGMA foreign_key_check")?;
415 let mut foreign_key_errors = 0;
416 let mut rows = foreign.query([])?;
417 while rows.next()?.is_some() { foreign_key_errors += 1; }
418 Ok(HealthReport {
419 schema_version: schema::SCHEMA_VERSION,
420 revision: current_revision(conn)?,
421 indexed_revision: meta(conn, "indexed_revision")?, record_count,
422 index_document_count: self.index()?.document_count(),
423 pending_index_updates: conn.query_row("SELECT COUNT(*) FROM index_updates", [], |r| r.get::<_, i64>(0))? as usize,
424 sqlite_integrity: conn.query_row("PRAGMA quick_check", [], |r| r.get(0))?,
425 foreign_key_errors, counts,
426 embedder_spaces: self.engine.embedders.space_ids(),
427 reranker_registered: self.engine.rerankers.is_registered(),
428 last_degraded: self.engine.degraded.lock().clone(),
429 })
430 }
431
432 pub fn backup(&self, target: impl AsRef<Path>) -> Result<()> {
434 let target = target.as_ref();
435 let state = self.read()?;
436 let reservation = OpenOptions::new().write(true).create_new(true).open(target)?;
437 drop(reservation);
438 if let Err(err) = state.conn().backup(rusqlite::MAIN_DB, target, None) {
439 let _ = std::fs::remove_file(target);
440 return Err(err.into());
441 }
442 Ok(())
443 }
444
445 pub fn restore(snapshot: impl AsRef<Path>, directory: impl AsRef<Path>) -> Result<Self> {
447 let source = Connection::open_with_flags(snapshot.as_ref(), rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
448 let app: i64 = source.pragma_query_value(None, "application_id", |r| r.get(0))?;
449 let version: i64 = source.pragma_query_value(None, "user_version", |r| r.get(0))?;
450 if app != schema::APPLICATION_ID { return Err(Error::Validation("snapshot is not a p-memory database".into())); }
451 if version != schema::SCHEMA_VERSION { return Err(Error::SchemaVersion { found: version, supported: schema::SCHEMA_VERSION }); }
452 std::fs::create_dir(directory.as_ref())?;
453 source.backup(rusqlite::MAIN_DB, directory.as_ref().join("store.sqlite3"), None)?;
454 Self::open(directory)
455 }
456}
457
458pub(crate) fn now_us() -> i64 { chrono::Utc::now().timestamp_micros() }
459pub(crate) fn meta(conn: &Connection, key: &str) -> Result<i64> {
460 Ok(conn.query_row("SELECT value FROM meta WHERE key=?1", [key], |r| r.get(0))?)
461}
462
463pub(crate) fn meta_opt(conn: &Connection, key: &str) -> Result<Option<i64>> {
465 Ok(conn.query_row("SELECT value FROM meta WHERE key=?1", [key], |r| r.get(0)).optional()?)
466}
467
468pub(crate) fn set_meta(conn: &Connection, key: &str, value: i64) -> Result<()> {
470 conn.execute("INSERT INTO meta(key,value) VALUES (?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value", params![key, value])?;
471 Ok(())
472}
473
474pub(crate) fn clear_meta(conn: &Connection, key: &str) -> Result<()> {
476 conn.execute("DELETE FROM meta WHERE key=?1", [key])?;
477 Ok(())
478}
479pub(crate) fn current_revision(conn: &Connection) -> Result<i64> { meta(conn, "revision") }
480pub(crate) fn next_revision(conn: &Connection, record_id: i64) -> Result<i64> {
481 conn.execute("UPDATE meta SET value=value+1 WHERE key='revision'", [])?;
482 let revision = current_revision(conn)?;
483 conn.execute("INSERT INTO index_updates(revision,record_id) VALUES (?1,?2)", params![revision, record_id])?;
484 Ok(revision)
485}
486
487pub(crate) fn term_id(conn: &Connection, text_value: &str) -> Result<i64> {
489 let normalized = text::normalized_tag(text_value);
490 conn.execute("INSERT OR IGNORE INTO strings(text) VALUES (?1)", [&normalized])?;
491 Ok(conn.query_row("SELECT id FROM strings WHERE text=?1", [&normalized], |r| r.get(0))?)
492}
493
494pub(crate) fn term_text(conn: &Connection, id: i64) -> Result<String> {
495 Ok(conn.query_row("SELECT text FROM strings WHERE id=?1", [id], |r| r.get(0))?)
496}
497
498pub(crate) fn record_namespaces(conn: &Connection) -> Result<Vec<String>> {
501 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")?;
502 let mut namespaces = Vec::new();
503 for row in stmt.query_map([], |r| r.get::<_, String>(0))? { namespaces.push(row?); }
504 Ok(namespaces)
505}
506
507pub(crate) fn validate_identity(label: &str, value: &str) -> Result<()> {
508 if value.trim().is_empty() || value != value.trim() || value.chars().any(char::is_control) {
509 return Err(Error::Validation(format!("{label} must be nonempty, trimmed, and contain no control characters")));
510 }
511 Ok(())
512}
513pub(crate) fn validate_filter(filter: &ReadFilter) -> Result<()> {
514 validate_identity("namespace", &filter.namespace)?;
515 if filter.scopes.is_empty() { return Err(Error::Validation("at least one explicit read scope is required".into())); }
516 for scope in &filter.scopes { validate_identity("scope", scope)?; }
517 Ok(())
518}
519pub(crate) fn validate_limit(limit: usize) -> Result<()> {
520 if !(1..=10_000).contains(&limit) { return Err(Error::Validation("limit must be between 1 and 10000".into())); }
521 Ok(())
522}
523
524pub(crate) fn normalize_tags(tags: &[String]) -> Vec<String> {
526 tags.iter().map(|label| text::normalized_tag(label)).filter(|tag| !tag.is_empty()).collect::<BTreeSet<_>>().into_iter().collect()
527}
528
529pub(crate) fn tags_prefix(kind: RecordKind, tags: &[String], exclude: &[String], payload: &Value) -> String {
534 let carries = match kind {
535 RecordKind::Note => false,
536 RecordKind::Chunk => payload.get("ordinal").and_then(Value::as_u64) == Some(0),
537 _ => true,
538 };
539 if !carries { return String::new(); }
540 tags.iter().filter(|tag| !exclude.contains(tag)).cloned().collect::<Vec<_>>().join(" ")
541}
542
543pub(crate) fn split_note_path(relative: &str) -> (Vec<String>, String) {
546 let segments: Vec<&str> = relative.split('/').filter(|segment| !segment.is_empty()).collect();
547 let Some((last, dirs)) = segments.split_last() else { return (Vec::new(), String::new()); };
548 let stem = last.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(last).trim();
549 (dirs.iter().map(|segment| segment.to_string()).collect(), stem.to_string())
550}
551
552pub(crate) fn note_path_parts(conn: &Connection, note_id: i64) -> (Vec<String>, String) {
556 let Ok((path, name)) = conn.query_row("SELECT path,name FROM notes WHERE record_id=?1", [note_id],
557 |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) else {
558 return (Vec::new(), String::new());
559 };
560 if Path::new(&path).is_absolute() { return (Vec::new(), name); }
561 (split_note_path(&path).0, name)
562}
563
564pub(crate) fn index_columns(conn: &Connection, kind: RecordKind, payload: &Value) -> (String, String, Vec<String>) {
569 if kind == RecordKind::Chunk {
570 if payload.get("ordinal").and_then(Value::as_u64).unwrap_or(0) != 0 { return (String::new(), String::new(), Vec::new()); }
572 let note_id = payload.get("note_id").and_then(Value::as_i64).unwrap_or(0);
573 let (dirs, stem) = note_path_parts(conn, note_id);
574 let mut exclude = dirs.clone();
575 if !stem.is_empty() { exclude.push(stem.clone()); }
576 return (stem, dirs.join(" "), exclude);
577 }
578 (record_name(kind, payload), String::new(), Vec::new())
579}
580
581pub(crate) fn put_record(conn: &Connection, kind: RecordKind, input: &RecordInput,
582 payload: &Value, text: &str) -> Result<(RecordHeader, crate::index::IndexDocument)> {
583 validate_identity("namespace", &input.namespace)?;
584 validate_identity("scope", &input.scope)?;
585 for evidence in &input.evidence {
586 if evidence.source.trim().is_empty() { return Err(Error::Validation("evidence source is required".into())); }
587 match (evidence.offset, evidence.limit) {
588 (None, None) => {},
589 (Some(offset), Some(limit)) if offset >= 1 && limit >= 1 => {},
590 _ => return Err(Error::Validation("evidence offset/limit must be a 1-based start and a positive line count".into())),
591 }
592 }
593 let namespace_id = term_id(conn, &input.namespace)?;
594 touch_namespace(&input.namespace);
596 let scope_id = term_id(conn, &input.scope)?;
597 let existing = match input.id {
598 Some(id) => Some(conn.query_row("SELECT created_at_us,updated_at_us,revision,scope_id FROM records WHERE id=?1", [id],
599 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, i64>(3)?))).optional()?
600 .ok_or_else(|| Error::NotFound(id.to_string()))?),
601 None => None,
602 };
603 if existing.as_ref().is_some_and(|v| v.3 != scope_id) {
604 return Err(Error::Conflict("an existing record cannot change scope; copy it to a new ID explicitly".into()));
605 }
606 if let Some(expected) = input.expected_revision {
607 if existing.as_ref().map(|v| v.2) != Some(expected) { return Err(Error::StaleRevision(input.id.map(|v| v.to_string()).unwrap_or_default())); }
608 }
609 let now = now_us();
610 let created = existing.as_ref().map(|v| v.0).unwrap_or(input.created_at_us.unwrap_or(now));
611 let updated = input.updated_at_us.unwrap_or_else(|| now.max(existing.as_ref().map(|v| v.1).unwrap_or(created)));
612 if updated < created { return Err(Error::Validation("updated_at_us precedes created_at_us".into())); }
613 let tags = normalize_tags(&input.tags);
615 let fingerprint = record_fingerprint(text, &tags);
616 let metadata_json = serde_json::to_string(&input.metadata)?;
617 let evidence_json = serde_json::to_string(&input.evidence)?;
618 let payload_json = serde_json::to_string(payload)?;
619 let (id, revision) = match input.id {
620 Some(id) => {
621 let revision = next_revision(conn, id)?;
622 conn.execute("UPDATE records SET namespace_id=?2,kind=?3,scope_id=?4,updated_at_us=?5,revision=?6,metadata_json=?7,
623 evidence_json=?8,fingerprint=?9,payload_json=?10 WHERE id=?1",
624 params![id, namespace_id, kind.code(), scope_id, updated, revision, metadata_json, evidence_json,
625 fingerprint, payload_json])?;
626 conn.execute("DELETE FROM embeddings WHERE record_id=?1 AND fingerprint<>?2", params![id, fingerprint])?;
628 (id, revision)
629 }
630 None => {
631 conn.execute("INSERT INTO records(namespace_id,kind,scope_id,created_at_us,updated_at_us,revision,metadata_json,evidence_json,
632 fingerprint,payload_json) VALUES (?1,?2,?3,?4,?5,0,?6,?7,?8,?9)",
633 params![namespace_id, kind.code(), scope_id, created, updated, metadata_json, evidence_json,
634 fingerprint, payload_json])?;
635 let id = conn.last_insert_rowid();
636 let revision = next_revision(conn, id)?;
637 conn.execute("UPDATE records SET revision=?2 WHERE id=?1", params![id, revision])?;
638 (id, revision)
639 }
640 };
641 let tag_ids = set_record_tags(conn, id, &tags)?;
642 let (name, path, exclude) = index_columns(conn, kind, payload);
645 let document = crate::index::IndexDocument { id, namespace_id, scope_id, kind,
646 text: text.to_string(), name, path,
647 note_id: if kind == RecordKind::Chunk { payload.get("note_id").and_then(Value::as_i64).unwrap_or(0) } else { 0 },
648 tags_prefix: tags_prefix(kind, &tags, &exclude, payload), tag_ids };
649 Ok((RecordHeader { id, namespace: input.namespace.clone(), kind, scope: input.scope.clone(),
650 created_at_us: created, updated_at_us: updated, revision, tags,
651 evidence: input.evidence.clone(), metadata: input.metadata.clone() }, document))
652}
653
654pub(crate) fn record_fingerprint(text: &str, tags: &[String]) -> String {
656 text::digest(&format!("text-v1\n{text}\n{}", tags.join(" ")))
657}
658
659pub(crate) fn set_record_tags(conn: &Connection, id: i64, tags: &[String]) -> Result<Vec<i64>> {
661 conn.execute("DELETE FROM record_tags WHERE record_id=?1", [id])?;
662 let mut tag_ids = Vec::with_capacity(tags.len());
663 for tag in tags {
664 let tag_id = term_id(conn, tag)?;
665 conn.execute("INSERT OR IGNORE INTO record_tags(record_id,tag_id) VALUES (?1,?2)", params![id, tag_id])?;
666 tag_ids.push(tag_id);
667 }
668 Ok(tag_ids)
669}
670
671pub(crate) fn record_tag_pairs(conn: &Connection, id: i64) -> Result<Vec<(i64, String)>> {
673 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")?;
674 let mut pairs = Vec::new();
675 for row in stmt.query_map([id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? { pairs.push(row?); }
676 Ok(pairs)
677}
678
679pub(crate) fn index_document(conn: &Connection, id: i64, kind: RecordKind, text: String) -> Result<crate::index::IndexDocument> {
681 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",
682 [id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
683 let payload: Value = serde_json::from_str(&payload_json)?;
684 let pairs = record_tag_pairs(conn, id)?;
685 let tags: Vec<String> = pairs.iter().map(|(_, tag)| tag.clone()).collect();
686 let (name, path, exclude) = index_columns(conn, kind, &payload);
687 Ok(crate::index::IndexDocument { id, namespace_id, scope_id, kind, text,
688 name, path,
689 note_id: if kind == RecordKind::Chunk { payload.get("note_id").and_then(Value::as_i64).unwrap_or(0) } else { 0 },
690 tags_prefix: tags_prefix(kind, &tags, &exclude, &payload),
691 tag_ids: pairs.into_iter().map(|(tag_id, _)| tag_id).collect() })
692}
693
694pub(crate) fn chunk_notes(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, (i64, usize)>> {
698 let mut out = BTreeMap::new();
699 if ids.is_empty() { return Ok(out); }
700 let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
701 let mut stmt = conn.prepare(&format!("SELECT record_id,note_id,\"offset\" FROM chunks WHERE record_id IN ({placeholders})"))?;
702 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)?)))? {
703 let (id, note_id, offset) = row?;
704 out.insert(id, (note_id, offset.max(0) as usize));
705 }
706 Ok(out)
707}
708
709pub(crate) fn namespace_root(conn: &Connection, namespace_id: i64) -> Result<Option<String>> {
711 Ok(conn.query_row("SELECT root FROM namespace_roots WHERE namespace_id=?1", [namespace_id], |r| r.get(0)).optional()?)
712}
713
714pub(crate) fn absolute_note_path(conn: &Connection, namespace_id: i64, stored: &str) -> String {
716 match namespace_root(conn, namespace_id) {
717 Ok(Some(root)) => Path::new(&root).join(stored.replace('/', std::path::MAIN_SEPARATOR_STR)).to_string_lossy().into_owned(),
718 _ => stored.to_string(),
719 }
720}
721
722pub(crate) fn record_value(conn: &Connection, key: &RecordKey) -> Result<Option<Value>> {
723 Ok(record_values(conn, &[key.id])?.remove(&key.id))
724}
725
726pub(crate) fn record_values(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, Value>> {
729 let mut out = BTreeMap::new();
730 if ids.is_empty() { return Ok(out); }
731 let placeholders = vec!["?"; ids.len()].join(",");
732 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
733 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,
734 r.metadata_json,r.evidence_json,r.payload_json FROM records r
735 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"))?;
736 let mut rows: Vec<(i64, i64, String, i64, String, i64, i64, i64, String, String, String)> = Vec::new();
737 for row in stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?,
738 r.get::<_, String>(2)?, r.get::<_, i64>(3)?, r.get::<_, String>(4)?, r.get::<_, i64>(5)?, r.get::<_, i64>(6)?,
739 r.get::<_, i64>(7)?, r.get::<_, String>(8)?, r.get::<_, String>(9)?, r.get::<_, String>(10)?)))? {
740 rows.push(row?);
741 }
742 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 \
743 WHERE rt.record_id IN ({placeholders}) ORDER BY rt.record_id,t.text"))?;
744 let mut tags: BTreeMap<i64, Vec<String>> = BTreeMap::new();
745 for row in tags_stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
746 let (id, tag) = row?;
747 tags.entry(id).or_default().push(tag);
748 }
749 let mut note_meta: BTreeMap<i64, (String, String)> = BTreeMap::new();
752 {
753 let mut stmt = conn.prepare(&format!("SELECT n.record_id,n.path,n.name FROM notes n \
754 WHERE n.record_id IN ({placeholders})"))?;
755 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)?)))? {
756 let (id, path, name) = row?;
757 note_meta.insert(id, (path, name));
758 }
759 }
760 for (id, namespace_id, namespace, kind_code, scope, created, updated, revision, metadata, evidence, payload) in rows {
761 let kind = RecordKind::from_code(kind_code).ok_or_else(|| Error::Validation("invalid stored record kind".into()))?;
762 let header = RecordHeader { id, namespace, kind, scope,
763 created_at_us: created, updated_at_us: updated, revision, tags: tags.remove(&id).unwrap_or_default(),
764 metadata: serde_json::from_str(&metadata)?, evidence: serde_json::from_str(&evidence)? };
765 let mut value = serde_json::to_value(header)?;
766 let object = value.as_object_mut().ok_or_else(|| Error::Validation("invalid stored header".into()))?;
767 let mut payload: Metadata = serde_json::from_str(&payload)?;
768 if let Some(type_id) = payload.get("memory_type_id").and_then(Value::as_i64) {
770 payload.insert("memory_type".into(), Value::String(term_text(conn, type_id)?));
771 payload.remove("memory_type_id");
772 }
773 if kind == RecordKind::Note {
774 let (stored, name) = note_meta.remove(&id).unwrap_or_default();
776 let source = absolute_note_path(conn, namespace_id, &stored);
777 payload.insert("source".into(), Value::String(source));
778 payload.insert("title".into(), Value::String(name));
779 }
780 object.extend(payload);
781 out.insert(id, value);
782 }
783 Ok(out)
784}
785
786pub(crate) fn matches_filter(conn: &Connection, key: &RecordKey, filter: &ReadFilter) -> Result<bool> {
787 validate_filter(filter)?;
788 let row: Option<(i64, i64)> = conn.query_row("SELECT namespace_id,scope_id FROM records WHERE id=?1", [key.id],
789 |r| Ok((r.get(0)?, r.get(1)?))).optional()?;
790 let Some((namespace_id, scope_id)) = row else { return Ok(false) };
791 if term_text(conn, namespace_id)? != text::normalized_tag(&filter.namespace) { return Ok(false); }
792 let scope = term_text(conn, scope_id)?;
793 if !filter.scopes.iter().any(|s| text::normalized_tag(s) == scope) { return Ok(false); }
794 for tag in &filter.tags {
795 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)",
796 params![key.id, text::normalized_tag(tag)], |r| r.get(0))?;
797 if !exists { return Ok(false); }
798 }
799 Ok(true)
800}
801
802pub(crate) fn get<T: DeserializeOwned>(conn: &Connection, key: &RecordKey, filter: &ReadFilter) -> Result<T> {
803 if !matches_filter(conn, key, filter)? { return Err(Error::NotFound(key.id.to_string())); }
804 serde_json::from_value(record_value(conn, key)?.ok_or_else(|| Error::NotFound(key.id.to_string()))?).map_err(Error::from)
805}
806
807pub(crate) fn load_many<T: DeserializeOwned>(conn: &Connection, ids: &[i64], filter: &ReadFilter) -> Result<BTreeMap<i64, T>> {
810 let mut out = BTreeMap::new();
811 if ids.is_empty() { return Ok(out); }
812 validate_filter(filter)?;
813 let (condition, values) = filter_sql(filter, &[], true)?;
814 let placeholders = vec!["?"; ids.len()].join(",");
815 let mut stmt = conn.prepare(&format!("SELECT r.id FROM records r WHERE r.id IN ({placeholders}) AND {condition} ORDER BY r.id"))?;
816 let params = ids.iter().map(|id| SqlValue::Integer(*id)).chain(values).collect::<Vec<_>>();
817 let allowed = stmt.query_map(params_from_iter(params), |r| r.get::<_, i64>(0))?.collect::<std::result::Result<Vec<_>, _>>()?;
818 for (id, value) in record_values(conn, &allowed)? {
819 out.insert(id, serde_json::from_value(value)?);
820 }
821 Ok(out)
822}
823
824pub(crate) fn filter_sql(filter: &ReadFilter, kinds: &[RecordKind], by_ids: bool) -> Result<(String, Vec<SqlValue>)> {
832 validate_filter(filter)?;
833 let mut query = if by_ids {
834 "+r.namespace_id=(SELECT id FROM strings WHERE text=?)".to_string()
835 } else {
836 "r.namespace_id=(SELECT id FROM strings WHERE text=?)".to_string()
837 };
838 let mut values = vec![SqlValue::Text(text::normalized_tag(&filter.namespace))];
839 query.push_str(" AND r.scope_id IN (SELECT id FROM strings WHERE text IN (");
840 query.push_str(&vec!["?"; filter.scopes.len()].join(",")); query.push_str("))");
841 values.extend(filter.scopes.iter().map(|s| SqlValue::Text(text::normalized_tag(s))));
842 if !kinds.is_empty() {
843 query.push_str(" AND r.kind IN ("); query.push_str(&vec!["?"; kinds.len()].join(",")); query.push(')');
844 values.extend(kinds.iter().map(|k| SqlValue::Integer(k.code())));
845 }
846 for tag in &filter.tags {
847 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=?)");
848 values.push(SqlValue::Text(text::normalized_tag(tag)));
849 }
850 Ok((query, values))
851}
852
853pub(crate) fn count_matches(conn: &Connection, filter: &ReadFilter, kinds: &[RecordKind]) -> Result<usize> {
855 let (condition, values) = filter_sql(filter, kinds, false)?;
856 let count: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM records r WHERE {condition}"), params_from_iter(values), |r| r.get(0))?;
857 Ok(count as usize)
858}
859
860pub(crate) fn record_text(kind: RecordKind, payload: &Value) -> String {
864 let field = |key: &str| payload.get(key).and_then(Value::as_str).unwrap_or("").to_string();
865 match kind {
866 RecordKind::Memory => field("judgment"),
867 RecordKind::Entity => entity_body(payload),
868 RecordKind::Relation => format!("{} {} {} {}", field("subject_name"), field("predicate"), field("object_name"), field("reason")),
869 RecordKind::Event => format!("{} {} {} {}", field("name"), field("summary"), name_list(payload), field("reason")),
870 RecordKind::Note | RecordKind::Chunk => String::new(),
872 }
873}
874
875pub(crate) fn record_name(kind: RecordKind, payload: &Value) -> String {
877 match kind {
878 RecordKind::Entity => payload.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
879 _ => String::new(),
880 }
881}
882
883pub(crate) fn event_text_lengths(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, usize>> {
890 let mut out = BTreeMap::new();
891 if ids.is_empty() { return Ok(out); }
892 let placeholders = vec!["?"; ids.len()].join(",");
893 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
894 let field = |name: &str| format!(
895 "CASE WHEN json_type(r.payload_json,'$.{name}')='text' THEN json_extract(r.payload_json,'$.{name}') ELSE '' END");
896 let names = "COALESCE(CASE WHEN json_type(r.payload_json,'$.participant_names')='array' \
899 THEN (SELECT group_concat(j.value,' ') FROM json_each(r.payload_json,'$.participant_names') j \
900 WHERE j.type='text') ELSE '' END,'')";
901 let mut stmt = conn.prepare(&format!(
902 "SELECT r.id, LENGTH({} || ' ' || {} || ' ' || {names} || ' ' || {}) \
903 FROM records r WHERE r.id IN ({placeholders}) ORDER BY r.id",
904 field("name"), field("summary"), field("reason")))?;
905 for row in stmt.query_map(params_from_iter(params.iter().cloned()), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))? {
906 let (id, length) = row?;
907 out.insert(id, length.max(0) as usize);
908 }
909 Ok(out)
910}
911
912pub(crate) fn entity_names(conn: &Connection, ids: &[i64]) -> Result<BTreeMap<i64, String>> {
915 let mut out = BTreeMap::new();
916 if ids.is_empty() { return Ok(out); }
917 let placeholders = vec!["?"; ids.len()].join(",");
918 let mut stmt = conn.prepare(&format!("SELECT record_id,name FROM entities WHERE record_id IN ({placeholders})"))?;
919 let params = ids.iter().map(|id| SqlValue::Integer(*id)).collect::<Vec<_>>();
920 for row in stmt.query_map(params_from_iter(params), |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
921 let (id, name) = row?;
922 out.insert(id, name);
923 }
924 Ok(out)
925}
926
927fn name_list(payload: &Value) -> String {
928 payload.get("participant_names").and_then(Value::as_array)
929 .map(|names| names.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "))
930 .unwrap_or_default()
931}
932
933fn entity_body(payload: &Value) -> String {
934 let aliases = payload.get("aliases").and_then(Value::as_array)
935 .map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" ")).unwrap_or_default();
936 let summary = payload.get("summary").and_then(Value::as_str).unwrap_or("");
937 let attr_text = payload.get("attributes").and_then(Value::as_object).map(|attrs| {
938 attrs.iter().map(|(key, values)| {
939 let joined = values.as_array().map(|v| v.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" ")).unwrap_or_default();
940 format!("{key} {joined}")
941 }).collect::<Vec<_>>().join(" ")
942 }).unwrap_or_default();
943 format!("{aliases} {summary} {attr_text}")
944}
945
946pub(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)?;
947 if let Some(cursor) = after {
948 let id: i64 = cursor.parse().map_err(|_| Error::Validation("invalid page cursor".into()))?;
949 condition.push_str(" AND r.id>?");
950 values.push(SqlValue::Integer(id));
951 }
952 values.push(SqlValue::Integer(limit.min(i64::MAX as usize) as i64));
953 let mut stmt = conn.prepare(&format!("SELECT r.id FROM records r WHERE {condition} ORDER BY r.id LIMIT ?"))?;
954 let rows = stmt.query_map(params_from_iter(values), |r| r.get::<_, i64>(0))?;
955 let mut keys = Vec::new();
956 for row in rows { keys.push(RecordKey { id: row? }); }
957 Ok(keys)
958}
959
960pub(crate) fn list<T: DeserializeOwned>(conn: &Connection, kind: RecordKind, request: &PageRequest) -> Result<Page<T>> {
961 validate_limit(request.limit)?;
962 let mut keys = select_keys(conn, &request.filter, &[kind], request.limit + 1, request.after.as_deref())?;
963 let has_more = keys.len() > request.limit;
964 keys.truncate(request.limit);
965 let next_cursor = if has_more { keys.last().map(RecordKey::index_key) } else { None };
966 let ids: Vec<i64> = keys.iter().map(|key| key.id).collect();
968 let mut loaded: BTreeMap<i64, T> = load_many(conn, &ids, &request.filter)?;
969 let items = keys.into_iter().filter_map(|key| loaded.remove(&key.id)).collect::<Vec<_>>();
970 Ok(Page { items, next_cursor })
971}
972
973pub(crate) fn delete_record(conn: &Connection, key: &RecordKey) -> Result<bool> {
974 let namespace = namespace_of(conn, key.id)?;
977 let changed = conn.execute("DELETE FROM records WHERE id=?1", [key.id]);
978 let changed = match changed {
979 Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation =>
980 return Err(Error::Conflict(format!("record {} is still referenced", key.id))),
981 other => other?,
982 };
983 if changed > 0 {
984 next_revision(conn, key.id)?;
985 if let Some(namespace) = namespace { touch_namespace(&namespace); }
986 }
987 Ok(changed > 0)
988}
989
990#[cfg(test)]
991mod tests {
992 use super::*;
993
994 fn batched_load_plan(conn: &Connection, ids: &[i64], by_ids: bool) -> String {
996 let (condition, values) = filter_sql(&ReadFilter::default(), &[], by_ids).unwrap();
997 let placeholders = vec!["?"; ids.len()].join(",");
998 let sql = format!("EXPLAIN QUERY PLAN SELECT r.id FROM records r WHERE r.id IN ({placeholders}) AND {condition} ORDER BY r.id");
999 let params: Vec<SqlValue> = ids.iter().map(|id| SqlValue::Integer(*id)).chain(values).collect();
1000 let mut stmt = conn.prepare(&sql).unwrap();
1001 let plans: Vec<String> = stmt.query_map(params_from_iter(params), |row| row.get::<_, String>(3))
1002 .unwrap().map(|row| row.unwrap()).collect();
1003 plans.join(" | ")
1004 }
1005
1006 fn seed(kb: &KnowledgeBase, rows: i64) {
1007 let inputs: Vec<crate::MemoryInput> = (1..=rows).map(|i| crate::MemoryInput::new(format!("记录 {i}"))).collect();
1008 kb.memories().upsert_many(&inputs).unwrap();
1009 }
1010
1011 fn sample_ids() -> Vec<i64> { (1..=10).collect() }
1012
1013 #[test]
1016 fn batched_load_stays_on_the_primary_key() {
1017 let dir = tempfile::tempdir().unwrap();
1018 let kb = KnowledgeBase::open(dir.path()).unwrap();
1019 seed(&kb, 100);
1020 let guard = kb.read().unwrap();
1021 let plan = batched_load_plan(guard.conn(), &sample_ids(), true);
1022 assert!(plan.contains("INTEGER PRIMARY KEY"), "批量取回退化为扫索引:{plan}");
1023 }
1024
1025 #[test]
1028 fn event_text_lengths_match_record_text() {
1029 let dir = tempfile::tempdir().unwrap();
1030 let kb = KnowledgeBase::open(dir.path()).unwrap();
1031 let entity = |name: &str| crate::EntityInput { record: Default::default(), name: name.into(),
1032 entity_type: "person".into(), aliases: vec![], attributes: BTreeMap::new(), summary: String::new() };
1033 let created = kb.graph().apply_batch(&crate::GraphBatch {
1034 entities: vec![entity("甲"), entity("乙")], ..Default::default()
1035 }).unwrap().value;
1036 let (first, second) = (created.entities[0].header.id, created.entities[1].header.id);
1037 let created = kb.graph().apply_batch(&crate::GraphBatch {
1038 events: vec![
1039 crate::EventInput { record: Default::default(), name: "别鹤典仪".into(), summary: "两人同去".into(),
1040 participants: vec![first, second], confidence: 1.0, reason: "有人证".into() },
1041 crate::EventInput { record: Default::default(), name: "堂中自语".into(), summary: String::new(),
1042 participants: vec![first], confidence: 1.0, reason: String::new() },
1043 ], ..Default::default()
1044 }).unwrap().value;
1045 let ids: Vec<i64> = created.events.iter().map(|event| event.header.id).collect();
1046
1047 {
1049 let raw = Connection::open(dir.path().join("store.sqlite3")).unwrap();
1050 let payloads = [
1051 r#"{"name":7,"summary":"只剩数字名","participant_names":"甲 乙","reason":null}"#,
1052 r#"{"name":"正常","summary":null,"participant_names":["甲",7,"乙"],"reason":"理由"}"#,
1053 ];
1054 for (id, payload) in ids.iter().zip(payloads) {
1055 raw.execute("UPDATE records SET payload_json=?1 WHERE id=?2", params![payload, id]).unwrap();
1056 }
1057 }
1058
1059 let guard = kb.read().unwrap();
1060 let conn = guard.conn();
1061 let lengths = event_text_lengths(conn, &ids).unwrap();
1062 assert_eq!(lengths.len(), ids.len(), "每条事件都该有长度");
1063 for id in ids {
1064 let payload = record_values(conn, &[id]).unwrap().remove(&id).unwrap();
1065 assert_eq!(lengths[&id], record_text(RecordKind::Event, &payload).chars().count(),
1066 "事件 {id} 的 SQL 长度与 record_text 不一致");
1067 }
1068 }
1069
1070 fn fixture_space() -> crate::embeddings::EmbeddingSpace {
1072 crate::embeddings::EmbeddingSpace { id: "v".into(), model: "fixture/v1".into(),
1073 dimension: 2, text_version: 1, encoding: "f32".into() }
1074 }
1075
1076 fn cache_partition(kb: &KnowledgeBase, space: &crate::embeddings::EmbeddingSpace, namespace: &str) {
1078 let guard = kb.read().unwrap();
1079 kb.partition(guard.conn(), space, namespace, "public").unwrap();
1080 }
1081
1082 fn cached_namespaces(kb: &KnowledgeBase) -> BTreeSet<String> {
1084 kb.engine.vectors.entries.lock().keys().map(|(_, namespace, _)| namespace.clone()).collect()
1085 }
1086
1087 fn namespace_filter(namespace: &str) -> ReadFilter {
1088 ReadFilter { namespace: namespace.into(), scopes: vec!["public".into()], tags: vec![], note_ids: vec![] }
1089 }
1090
1091 #[test]
1093 fn invalidating_one_namespace_leaves_the_others_alone() {
1094 let cache = VectorCache::new();
1095 let key = |namespace: &str| ("v".to_string(), namespace.to_string(), "public".to_string());
1096 cache.entries.lock().insert(key("a"), None);
1097 cache.entries.lock().insert(key("b"), None);
1098 let epoch_b = cache.epoch_of("b");
1099
1100 cache.invalidate_namespaces(&HashSet::from(["a".to_string()]));
1101
1102 assert!(cache.entries.lock().get(&key("a")).is_none(), "写过的领域要清掉条目");
1103 assert!(cache.entries.lock().get(&key("b")).is_some(), "没写过的领域不该被牵连");
1104 assert_eq!(cache.epoch_of("b"), epoch_b, "没写过的领域版本号不动");
1105 assert_ne!(cache.epoch_of("a"), epoch_b, "写过的领域版本号要前进,在途载入才会作废");
1106
1107 let epoch_a = cache.epoch_of("a");
1109 cache.invalidate();
1110 assert!(cache.entries.lock().is_empty());
1111 assert!(cache.epoch_of("a") > epoch_a && cache.epoch_of("b") > epoch_b);
1112 }
1113
1114 #[test]
1116 fn writing_one_namespace_keeps_other_vector_partitions_cached() {
1117 let dir = tempfile::tempdir().unwrap();
1118 let kb = KnowledgeBase::open(dir.path()).unwrap();
1119 let space = fixture_space();
1120 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1121 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["a".to_string(), "b".to_string()]));
1122
1123 let mut input = crate::MemoryInput::new("写在 a 领域的一条");
1124 input.record.namespace = "a".into();
1125 kb.memories().upsert(input).unwrap();
1126
1127 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["b".to_string()]), "只该清掉被写的那个领域");
1128 }
1129
1130 #[test]
1132 fn deleting_a_record_evicts_only_its_own_namespace() {
1133 let dir = tempfile::tempdir().unwrap();
1134 let kb = KnowledgeBase::open(dir.path()).unwrap();
1135 let mut input = crate::MemoryInput::new("要被删掉的一条");
1136 input.record.namespace = "a".into();
1137 let id = kb.memories().upsert(input).unwrap().value.header.id;
1138
1139 let space = fixture_space();
1140 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1141 kb.memories().delete(id, &namespace_filter("a")).unwrap();
1142
1143 assert_eq!(cached_namespaces(&kb), BTreeSet::from(["b".to_string()]), "删掉的领域要清,别的领域留着");
1144 }
1145
1146 #[test]
1149 fn filling_vectors_only_evicts_the_namespaces_it_wrote() {
1150 let dir = tempfile::tempdir().unwrap();
1151 let kb = KnowledgeBase::open(dir.path()).unwrap();
1152 let space = fixture_space();
1153 kb.embeddings().register_space(space.clone()).unwrap();
1154 kb.embeddings().register_embedder("v", |texts: &[String]| -> std::result::Result<Vec<Vec<f32>>, crate::EmbedCallbackError> {
1155 Ok(texts.iter().map(|_| vec![1.0f32, 0.0]).collect())
1156 }).unwrap();
1157 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1158 kb.memories().upsert(crate::MemoryInput::new("补齐用的一条")).unwrap();
1159 kb.embeddings().sync("v", 32).unwrap();
1160
1161 let cached = cached_namespaces(&kb);
1162 assert!(cached.contains("a") && cached.contains("b"),
1163 "补齐只写了 default 领域,a 与 b 的分区缓存不该被牵连:{cached:?}");
1164 }
1165
1166 #[test]
1169 fn an_unregistered_write_falls_back_to_invalidating_everything() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let kb = KnowledgeBase::open(dir.path()).unwrap();
1172 let space = fixture_space();
1173 for namespace in ["a", "b"] { cache_partition(&kb, &space, namespace); }
1174
1175 kb.mutate(|tx| Ok(tx.execute("INSERT INTO meta(key,value) VALUES ('cache_probe',1)
1177 ON CONFLICT(key) DO UPDATE SET value=excluded.value", [])?)).unwrap();
1178
1179 assert!(cached_namespaces(&kb).is_empty(), "登记为空却改过行时必须整体失效");
1180 }
1181}