Skip to main content

p_memory/
storage.rs

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
10/// 读路径自愈索引时最多试几次拿写锁(每次退避 1ms,合计约 1s)。
11/// 只在库内补向量那条线程正在补齐时才试:它提交的正是读者要看的待办,
12/// 而那份提交一落地待办就空了,循环随即结束。别的时候(例如批量导入的写者占着写锁)
13/// 一律不试,读路径绝不为索引排队——那正是当初吞吐塌方的成因。
14const SELF_HEAL_ATTEMPTS: usize = 1000;
15
16/// 写者:独占的写连接 + 跨进程文件锁。只挡其他写者,不挡读。
17pub(crate) struct Writer { pub conn: Connection, _file_lock: File }
18
19/// 只读连接池。`rusqlite::Connection` 不是 `Sync`,并发读必须各持一条独立连接;
20/// 池锁只在取出与归还时短暂持有,读的整个过程不占任何全局锁。
21pub(crate) struct Readers { pub idle: Vec<Connection> }
22
23/// 向量分区缓存。失效按知识领域分开做:一个领域写了,别的领域已经载入的分区照旧留着。
24/// 条目留在表里就意味着可用——失效是就地删条目,不靠条目自带的版本号比对。
25/// 版本号仍要按领域记:载入期间该领域若被写过,手上这份数据已经过期,不能再写进缓存。
26/// 版本号也必须独立于全局 revision:向量落盘不推进 revision,只盯 revision 会漏失效。
27pub(crate) struct VectorCache {
28    /// 全局代次,整体失效时 +1。所有领域的版本号随之上移,因此它只增不减。
29    generation: AtomicU64,
30    /// 领域自己的递增计数,只在这个领域被写时 +1。
31    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    /// 某个领域当前的版本号。没写过的领域就是全局代次本身。
40    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    /// 整体失效:清空所有条目,并让在途的载入结果不再被采用。
44    /// 清空是为了让内存有界——重新载入本来就是按需的。
45    pub fn invalidate(&self) {
46        self.generation.fetch_add(1, Ordering::SeqCst);
47        self.entries.lock().clear();
48    }
49    /// 只失效这些领域:删掉它们的条目、各自推进版本号,别的领域的缓存原样留着。
50    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    /// 本次写入事务动过的知识领域。写路径自己登记(`touch_namespace`),
61    /// `mutate` 装上它、提交后取走,用来精准失效对应领域的向量分区缓存。
62    /// 事务之外的写入(迁移、导入进度回写)登记不生效,那些路径本来就走整体失效。
63    static TOUCHED_NAMESPACES: std::cell::RefCell<Option<HashSet<String>>> = const { std::cell::RefCell::new(None) };
64}
65
66/// 登记本次事务动过的领域。只在事务里有效,事务外是空操作。
67/// 名字用归一化后的写法:检索侧的缓存键就是归一化后的领域名。
68pub(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
74/// 登记某条记录所属的领域。记录还在库里时才取得出名字。
75pub(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
80/// 某条记录所属的领域名;记录不存在时是 `None`。
81pub(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
86/// 事务期间装上的领域登记表。装与还原都走它,中途报错提前返回也不会把登记表留在外面。
87struct 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    /// 取走这次事务登记的领域;登记表本身由 `Drop` 还原。
94    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    /// `TextIndex` 自带内部写锁、`IndexReader` 可并发检索,用 `Arc` 共享给所有读线程。
110    /// 放进 `Option` 是为了 `close` 时能真正销毁它——Tantivy 的写锁由 `IndexWriter` 持有,
111    /// 不销毁就无法释放,目录也重开不了。
112    pub index: RwLock<Option<Arc<TextIndex>>>,
113    pub vectors: VectorCache,
114    /// 宿主注册的模型能力。它们是运行时状态(闭包 / Python 函数无法序列化),不落盘。
115    pub embedders: crate::embeddings::EmbedderRegistry,
116    pub rerankers: crate::search::RerankerRegistry,
117    /// 宿主注册的事件接收位。不注册就什么都不产出。
118    pub events: crate::events::EventRegistry,
119    /// 库内的向量化线程。它在 `open` 里启动,线程自己持弱引用,因此只能在这里设一次。
120    pub vectorizer: std::sync::OnceLock<Arc<crate::embeddings::Vectorizer>>,
121    /// 最近观察到的降级档位,供健康检查读出「结果为什么变差」。
122    pub degraded: Mutex<Vec<Degrade>>,
123    pub root: PathBuf,
124}
125
126/// 只读连接的新建:`journal_mode` 是库文件上的持久属性,无需在每条连接上重设。
127fn 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/// Clone shares one process-local engine. Close invalidates all its handles.
134#[derive(Clone)]
135pub struct KnowledgeBase { pub(crate) engine: Arc<Engine> }
136
137/// 一次只读访问:独占一条连接,析构时归还池中。
138pub(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        // 关闭后归还无处安放,直接丢弃即可。
148        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        // 只读连接在 schema 建好之后再开,保证它看到的是完整结构。
163        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        // 向量化线程在开库时启动,由 `close` 停下并等它收尾。
175        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    /// 取文本索引的共享句柄。只在这一瞬间持有索引锁,拿到 `Arc` 后即可并发使用。
182    pub(crate) fn index(&self) -> Result<Arc<TextIndex>> {
183        self.engine.index.read().clone().ok_or(Error::Closed)
184    }
185
186    /// 把写入流程就地交过来的索引文档写进 Tantivy:此刻只是写进 writer,
187    /// 对搜索不可见,commit 仍由 `update_index`(或关闭时的收尾)一次做完。
188    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        // 先叫停向量化线程并等它收尾:它可能正在补一批向量,不能在库拆到一半时还在写。
194        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        // 必须真正销毁索引:Tantivy 的目录写锁由 IndexWriter 持有,不销毁就释放不掉。
202        *self.engine.index.write() = None;
203        *self.engine.readers.lock() = None;
204        result
205    }
206
207    /// 取一条独占的只读连接。并发读各拿各的,互不等待。
208    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    /// 取一个向量分区:缓存里有就直接复用,否则按需载入后写入缓存。
223    /// 载入过程不持缓存锁——否则一个慢分区会挡住所有其他分区的查询。
224    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        // 载入期间这个领域可能被写过:版本号变了就说明手上这份已经过期,索性不写缓存。
232        // 复查版本号与写条目要在同一把条目锁里:否则失效正好落在两步之间时,
233        // 一份过期分区会被永久留在表里,直到这个领域下次被写。
234        {
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    /// 只在索引确实落后、且写者空闲时追平。
242    /// 稳态下待办队列是空的(写路径提交后已就地清空),这一次预检就让读完全不碰写锁。
243    /// 待办非空说明写入正在进行或上一次索引提交失败过:此时**只尝试、不等待**。
244    /// 抢不到写锁就说明写者正在提交索引,读路径绝不能为此排队——一次提交是二十毫秒量级,
245    /// 排队会把所有读都堵在门外。等待空闲时再补平,兼顾「上次提交失败后自愈」。
246    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        // 库内补向量那条线程也会提交索引,而它提交的正是读者要看的待办:
250        // 它补齐期间允许读者短暂等一等。别的写者一概不等——批量导入时写者会长期占着写锁,
251        // 读路径为它排队就是当初那次吞吐塌方的成因。
252        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                    // 待办已经被提交掉了(多半就是那条线程提交的):不必再等。
261                    let still: i64 = conn.query_row("SELECT COUNT(*) FROM index_updates", [], |r| r.get(0))?;
262                    if still == 0 { return Ok(()); }
263                    // 它没在补齐就说明是别的写者占着写锁,读路径不为它排队。
264                    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    /// 业务写入:改记录、改向量。提交后按领域精准失效向量分区缓存。
273    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        // 索引不在写入路径上追平——写入只把待办记进 index_updates,
285        // 由使用方稍后调用 `update_index` 一趟索引完、只提交一次。
286        // 向量缓存按领域失效:动过的领域就地清掉,没动过的照旧留着。
287        if touched.is_empty() {
288            // 登记为空却确实改过行:有写路径没登记,或者记录已删、领域无从查起。
289            // 宁可整体失效,也不留下一个来源说不清的陈旧分区。
290            if writer.conn.total_changes() > changed_before { self.engine.vectors.invalidate(); }
291        } else {
292            self.engine.vectors.invalidate_namespaces(&touched);
293        }
294        // 记录一变,「应向量化集合」就变了:受影响领域的就绪标记就地作废,
295        // 等补齐核对过再重新标。这样检索侧读到的永远是「核对过的那一份」。
296        self.invalidate_readiness(&writer.conn);
297        // 也给库内线程说一声:切片更新了就有一批该补的,叫它起来干活。
298        if let Some(vectorizer) = self.engine.vectorizer.get() { vectorizer.notify_work(); }
299        Ok(WriteReceipt { value, revision })
300    }
301
302    /// 只写那些不喂向量分区的表的写入:就绪标记、向量空间登记、各档开关、谓词规则。
303    /// 这类写入既不改记录也不改向量,向量分区缓存因此不动。
304    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    /// 作废待办记录所属领域的就绪标记。待办队列记着这次动了哪些记录,
315    /// 按记录所属领域取名字;已经删掉的记录查不到领域,不影响(删记录不会造出缺口)。
316    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    /// 需要写连接但不走事务的极少数场景(如导入进度回写)。
331    /// 走写锁,并保守失效向量缓存:调用方改了库里的东西,缓存不能不知情。
332    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    /// 记下一个降级档位,去重保留少量,供健康检查读出。
340    pub(crate) fn note_degrade(&self, degrade: Degrade) {
341        let mut observed = self.engine.degraded.lock();
342        if !observed.contains(&degrade) {
343            observed.push(degrade);
344            if observed.len() > 8 { observed.remove(0); }
345        }
346    }
347
348    /// 追平待索引队列:把写入累积的待办一趟索引完、只提交一次。
349    /// 写入不再就地索引,使用方(尤其批量导入)在合适时机调用本方法即可;
350    /// 期间读取走 `sync_index_if_behind` 自愈兜底。
351    pub fn update_index(&self) -> Result<HealthReport> {
352        self.catch_up_index()?;
353        self.health()
354    }
355
356    /// 只追平索引、不做健康检查。向量化前必须走一次:
357    /// 切片正文只存在索引里,索引没追平就取不到正文,只能拿到空串。
358    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    /// 注册事件接收回调。库在检索线程里同步调用它,所以回调必须非阻塞——
365    /// 在里面做同步 IO 或网络上报,会把检索拖住,和慢的重排回调一样。
366    /// 回调抛错只丢这一条事件,不影响检索。不注册就完全不产出事件。
367    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    /// 注销事件接收回调,返回此前是否有注册。
372    pub fn unregister_event_sink(&self) -> bool { self.engine.events.clear() }
373
374    /// 当前是否注册了事件接收回调。
375    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    /// 读一次全文索引重建的进度快照。纯原子读、不碰写锁,可在重建进行时从另一线程轮询。
399    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    /// Consistent SQLite snapshot, including embeddings. Refuses to overwrite a file.
433    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    /// Restores to a new directory; search indexes are rebuilt from the snapshot.
446    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
463/// 读一个可能不存在的 meta 键;不存在返回 `None`。用于重建游标这类运行期临时状态。
464pub(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
468/// 写入(存在则更新)一个 meta 键。键值表结构固定,新增键不需要 schema 迁移。
469pub(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
474/// 删除一个运行时 meta 键,不存在时无副作用。
475pub(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
487/// 登记一个开放标记并返回它的整数 id;字符串只在此表出现一次。
488pub(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
498/// 库里实际出现过的知识领域(记录用到的 namespace),按字典序。
499/// 就绪核对按它逐个领域做:没有记录的领域没有缺口可言。
500pub(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
524/// 归一化、去重后的标签文本(排序)。标签字符串统一落在 strings 表。
525pub(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
529/// 标签集拼进哪一条的可搜正文:笔记这条自己不占索引文档,由它的第一片承载;
530/// 其余记录没有切片,就是它自己。返回空格连接的标签文本,不带标签时是空串。
531/// `exclude` 是已经升格成独立索引列的那些标签(笔记的目录段与文件名)——它们靠专门的列
532/// 参与匹配,不能再留在正文里,否则搜目录名会命中该目录下每一篇。
533pub(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
543/// 笔记相对路径拆成「目录段」与「文件名(去扩展名)」。
544/// 目录段原样,只有最后一段去后缀(目录名里的点不是扩展名)。写入与重建共用同一套规则。
545pub(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
552/// 一篇笔记的「目录段 / 文件名」。文件名直接读写入时存下的 `notes.name`(不事后拆路径,
553/// 平台的路径分隔符与扩展名边界都由 `Path` 在写入那一刻定好);目录段取相对路径去掉末段。
554/// 登记根目录后库里存的必是相对路径;迁移前留下的绝对路径没有相对目录可言,只给名字。
555pub(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
564/// 一条记录在索引里的「名字列 / 目录列 / 从可搜前缀里摘除的标签」。
565/// 切片的名字列放所属笔记的文件名、目录列放它的目录段;这两样都已升格成独立列,
566/// 就从可搜前缀里摘掉——否则搜目录名会命中该目录下每一篇,文件名也会被正文列弱命中重复计一次。
567/// 其余记录只有名字列(实体规范名),目录列为空、无摘除。
568pub(crate) fn index_columns(conn: &Connection, kind: RecordKind, payload: &Value) -> (String, String, Vec<String>) {
569    if kind == RecordKind::Chunk {
570        // 名字列与目录列只挂在这一篇的第一片上:否则一篇的每一片都会命中同一查询、把结果刷屏。
571        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    // 记录一落库,这个领域的向量分区就变了:登记它,缓存按领域精准失效。
595    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    // 指纹跟着「送进搜索的文本 + 标签」走:正文或标签一变,各空间的旧向量立即失效。
614    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            // Updating text invalidates every space's embedding in the same transaction.
627            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    // 索引文档就地拼好交回调用方:正文来自本次写入手上的那一份,索引阶段不再回源。
643    // 标记一律带整数 id 给索引:namespace、scope、kind、tags 都不写第二份文本。
644    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
654/// 记录指纹:正文 + 标签一起算。两者任一变,各空间的旧向量立即失效。
655pub(crate) fn record_fingerprint(text: &str, tags: &[String]) -> String {
656    text::digest(&format!("text-v1\n{text}\n{}", tags.join(" ")))
657}
658
659/// 换掉一条记录的标签,返回这次的 tag id(按标签文本排序,与 `normalize_tags` 同序)。
660pub(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
671/// 一条记录的 (tag id, 标签文本),按文本排序。索引写 id 列与文本列都从这里取。
672pub(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
679/// 按库里现有状态给一条记录拼索引文档:标记(namespace / scope / tags)一律取 strings 表的 id。
680pub(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
694/// 一批记录 id 里哪些是切片,各自属于哪一篇笔记、在原文里从第几行起。非切片不出现在结果里。
695/// 「一篇笔记有多少片段命中」要按笔记精确统计,折叠后的代表命中要挂上「同一篇的其余片段」,
696/// 两件事靠的都是这层「切片 → 笔记」的归属。
697pub(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
709/// 该知识领域登记的笔记根目录;没登记就是 `None`。
710pub(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
714/// 库里存的相对路径 → 实际文件路径:登记过根目录就拼回去,没登记就是原样那条。
715pub(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
726/// 批量读取记录本体:一次查 records、一次查 tags,再由同一套装配逻辑还原。
727/// 语义等同于对每个 id 调用 `record_value`,只是把逐行往返压成固定两次查询。
728pub(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    // 笔记的路径与文件名都是它自己的列(原样):读取时按 record_id 补回,
750    // 路径不进标签字典,payload 里也不重复存。
751    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        // memory_type 也走 strings 文本表:payload 里只存 id,读取时还原文本。
769        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            // 库里存的是相对路径(登记过领域根目录时),对外取回时拼回绝对路径。
775            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
807/// 批量读取一批记录,只返回满足 `filter` 的那些。
808/// 语义等同于对每个 id 依次调用 `get`,但把过滤压成一条 SQL,避免逐条回库。
809pub(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
824/// 生成 `records` 表上的过滤条件。
825///
826/// `by_ids` 用于「手里已经有一批 record_id、只在这些 id 内做属性过滤」的查询:给
827/// `namespace_id` 加一元 `+`(SQLite 的 no-op,唯一作用是禁止该表达式用索引),
828/// 逼优化器以 IN 列表逐行点查主键。不加的话它会拿 `records_scope` 去扫该 namespace 下的
829/// 全部行——23998 行的库上取 10 条要 1167 µs,加 `+` 只要 12 µs,而且成本随库规模线性增长。
830/// 反之,`select_keys` 那类「按条件翻页、手里没有 id 列表」的查询必须靠索引来缩小范围,不能禁。
831pub(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
853/// 过滤条件下的匹配总数(截断之前)。`with_total` 打开时才调用。
854pub(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
860/// 记录的正文列内容:这条记录自己的文本。
861/// 切片正文来自写入时切好的那一段,不在这里算,所以这条纯函数只覆盖其余四种记录。
862/// 实体的正文不含规范名——规范名单独走 `record_name` 的 name 列,不在正文里占位。
863pub(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        // 笔记与切片的正文另有来源:笔记的检索面交给切片,切片正文由写入流程就地提供。
871        RecordKind::Note | RecordKind::Chunk => String::new(),
872    }
873}
874
875/// 记录的名字列内容:只有实体有规范名,其它记录为空串(检索时该列不参与)。
876pub(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
883/// 一批事件记录的正文长度(字符数),只含库里存在的 id。
884///
885/// 与 `record_text(RecordKind::Event, ..)` 是同一套拼法,只是走 SQL 算:事件那一路常常
886/// 一次取出比字符预算大一到两个数量级的一批 id,先知道长度就能只装配真正要留下的几条。
887/// 非字符串字段与缺失字段都按空串计,与 Rust 侧 `Value::as_str` 的口径一致;
888/// `participant_names` 不是数组时同样按空串计。
889pub(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    // 参与者名:不是数组时按空串计,是数组时只收文本元素(`j.type` 是元素的 JSON 类型名,
897    // 用 `json_type(j.value)` 会把已解包的文本再当 JSON 解析一次,直接报 malformed JSON)。
898    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
912/// 一批记录里属于实体的那些的规范名(按 record_id)。重排取文档时用:正文列已不含规范名,
913/// 纯名实体(别名、摘要、属性全空)的正文是空串,得把规范名拼回去才能让重排看到名字。
914pub(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    // 一次批量取回:逐条 `get` 会为每条记录各跑一遍过滤与装配,limit=100 就是几百次回库。
967    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    // Graph references are RESTRICT, so callers explicitly remove edges first.
975    // 领域名要在删之前取:删完这条记录就查不到它属于哪个领域了。
976    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    /// 按 id 批量取回时优化器选中的计划。
995    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    /// 手里已有 id 列表时,取回必须按主键点查。少了 `+`,优化器会去扫 records_scope 索引,
1014    /// 成本随库规模线性增长,而每一行都是额外的读放大。
1015    #[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    /// 事件正文长度走 SQL 算,必须与 Rust 侧 `record_text` 逐条一致:
1026    /// 两者一旦漂移,事件那一路的字符预算就截在别的地方,返回的事件跟着变。
1027    #[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        // 再把两条改成边角形态:非字符串字段、缺字段、participant_names 不是数组。
1048        {
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    // ── 向量分区缓存的按领域失效 ──────────────────────────────────────
1071    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    /// 让某个领域的向量分区进缓存。空领域也会被缓存成空分区,所以不必真造向量。
1077    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    /// 当前缓存着哪些领域的向量分区。
1083    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    /// 缓存失效按领域分开做:动过的领域清条目并推进版本号,没动过的条目与版本号都不动。
1092    #[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        // 整体失效:所有领域一起作废,版本号只增不减。
1108        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    /// 写一个领域,只该清掉那个领域已经载入的分区。
1115    #[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    /// 删记录同样精准失效。领域名必须在删之前取出来——删完这条记录就查不到它属于哪个领域了。
1131    #[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    /// 补齐向量跑一轮,只该作废它写了向量的领域。
1147    /// 补齐收尾会落就绪标记,那是只写不喂向量分区的写入,不能顺手把整个缓存清掉。
1148    #[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    /// 有写路径没登记领域时(按 id 删掉记录、或将来新增的写路径),宁可整体失效,
1167    /// 也不留下一个来源说不清的陈旧分区。
1168    #[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        // 直接改一行、不登记领域:模拟一条没接上登记的写路径。
1176        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}