Skip to main content

p_memory/
graph.rs

1use crate::{storage::{self, KnowledgeBase}, text, types::*, Error, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::collections::{BTreeMap, BTreeSet, HashSet};
6
7fn concept() -> String { "concept".into() }
8fn confidence() -> f64 { 0.8 }
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct EntityInput {
12    #[serde(flatten)] pub record: RecordInput,
13    pub name: String,
14    #[serde(default = "concept")] pub entity_type: String,
15    #[serde(default)] pub aliases: Vec<String>,
16    #[serde(default)] pub attributes: BTreeMap<String, Vec<String>>,
17    #[serde(default)] pub summary: String,
18}
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Entity {
21    #[serde(flatten)] pub header: RecordHeader,
22    pub name: String, pub entity_type: String, pub aliases: Vec<String>,
23    pub attributes: BTreeMap<String, Vec<String>>, pub summary: String,
24}
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct RelationInput {
27    #[serde(flatten)] pub record: RecordInput,
28    pub subject_id: i64, pub predicate: String, pub object_id: i64,
29    #[serde(default = "confidence")] pub confidence: f64,
30    #[serde(default)] pub reason: String,
31}
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Relation {
34    #[serde(flatten)] pub header: RecordHeader,
35    pub subject_id: i64, pub predicate: String, pub object_id: i64,
36    pub confidence: f64, pub reason: String,
37}
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct EventInput {
40    #[serde(flatten)] pub record: RecordInput,
41    pub name: String,
42    #[serde(default)] pub summary: String,
43    #[serde(default)] pub participants: Vec<i64>,
44    #[serde(default = "confidence")] pub confidence: f64,
45    #[serde(default)] pub reason: String,
46}
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Event {
49    #[serde(flatten)] pub header: RecordHeader,
50    pub name: String, pub summary: String, pub participants: Vec<i64>,
51    pub confidence: f64, pub reason: String,
52}
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct GraphBatch {
55    #[serde(default)] pub entities: Vec<EntityInput>,
56    #[serde(default)] pub relations: Vec<RelationInput>,
57    #[serde(default)] pub events: Vec<EventInput>,
58}
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct GraphBatchResult { pub entities: Vec<Entity>, pub relations: Vec<Relation>, pub events: Vec<Event> }
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Neighborhood { pub entities: Vec<Entity>, pub relations: Vec<Relation> }
63#[derive(Clone)]
64pub struct GraphStore(pub(crate) KnowledgeBase);
65
66fn check_confidence(score: f64) -> Result<()> {
67    if !score.is_finite() || !(0.0..=1.0).contains(&score) { return Err(Error::Validation("confidence must be in [0,1]".into())); }
68    Ok(())
69}
70
71/// 单次扩散返回的词元上限:防止宽泛等价词灌入过多词元、稀释检索精度。
72const EXPAND_QUERY_LIMIT: usize = 64;
73
74/// 按文本查已登记的领域 id;没登记返回 None(只读接口不新建标记)。
75fn namespace_term(conn: &Connection, namespace: &str) -> Result<Option<i64>> {
76    Ok(conn.query_row("SELECT id FROM strings WHERE text=?1", [text::normalized_tag(namespace)], |r| r.get(0)).optional()?)
77}
78
79/// 扩散核心,直接在给定连接上做:GraphStore::expand_query 与全文检索路共用。
80/// 先找出查询里出现的登记谓词,再取这些谓词所在等价组的全部同义词。
81pub(crate) fn expand_query_conn(conn: &Connection, namespace: &str, text_value: &str) -> Result<Vec<String>> {
82    let Some(namespace_id) = namespace_term(conn, namespace)? else { return Ok(Vec::new()); };
83    let normalized = text::normalized_tag(text_value);
84    let mut stmt = conn.prepare("SELECT s.text, pe.canonical_id FROM predicate_equivalents pe \
85        JOIN strings s ON s.id=pe.predicate_id WHERE pe.namespace_id=?1")?;
86    let mut canonical_ids: BTreeSet<i64> = BTreeSet::new();
87    for row in stmt.query_map([namespace_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
88        let (predicate_text, canonical_id) = row?;
89        let probe = text::normalized_tag(&predicate_text);
90        if !probe.is_empty() && normalized.contains(&probe) { canonical_ids.insert(canonical_id); }
91    }
92    if canonical_ids.is_empty() { return Ok(Vec::new()); }
93    let mut member_stmt = conn.prepare("SELECT s.text FROM predicate_equivalents pe JOIN strings s ON s.id=pe.predicate_id \
94        WHERE pe.namespace_id=?1 AND pe.canonical_id=?2 ORDER BY s.text")?;
95    let mut seen: HashSet<String> = HashSet::new();
96    let mut expanded: Vec<String> = Vec::new();
97    for canonical_id in &canonical_ids {
98        for row in member_stmt.query_map(params![namespace_id, canonical_id], |r| r.get::<_, String>(0))? {
99            let term = row?;
100            if seen.insert(term.clone()) { expanded.push(term); }
101            if expanded.len() >= EXPAND_QUERY_LIMIT { return Ok(expanded); }
102        }
103    }
104    Ok(expanded)
105}
106
107/// 查询期扩散:把命中的同义词追加到查询词后面,让「beta」也能召回写「alpha」的记录。
108/// 领域没登记等价词、或查询里没出现登记词时,原样返回。查询词用空格连接追加,
109/// 追加部分自成词元,不影响原有部分的分词结果。
110pub(crate) fn match_predicate_synonyms(conn: &Connection, namespace: &str, query: &str) -> Result<String> {
111    let extra = expand_query_conn(conn, namespace, query)?;
112    if extra.is_empty() { return Ok(query.to_string()); }
113    Ok(format!("{query} {}", extra.join(" ")))
114}
115
116fn referenced_entity(conn: &Connection, record: &RecordInput, id: i64) -> Result<Entity> {
117    storage::get(conn, &RecordKey { id },
118        &ReadFilter { namespace: record.namespace.clone(), scopes: vec![record.scope.clone()], tags: vec![], note_ids: vec![] })
119}
120
121pub(crate) fn upsert_entity(conn: &Connection, input: &EntityInput) -> Result<(Entity, crate::index::IndexDocument)> {
122    storage::validate_identity("entity name", &input.name)?;
123    storage::validate_identity("entity_type", &input.entity_type)?;
124    let aliases: Vec<_> = input.aliases.iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect::<BTreeSet<_>>().into_iter().collect();
125    let attributes = input.attributes.clone();
126    let attr_text = attributes.iter().map(|(k, values)| format!("{k} {}", values.join(" "))).collect::<Vec<_>>().join(" ");
127    let body = format!("{} {} {}", aliases.join(" "), input.summary, attr_text);
128    let (header, document) = storage::put_record(conn, RecordKind::Entity, &input.record,
129        &json!({"name":input.name,"entity_type":input.entity_type,"aliases":aliases,"attributes":attributes,"summary":input.summary}), &body)?;
130    let entity_type_id = storage::term_id(conn, &input.entity_type)?;
131    conn.execute("INSERT INTO entities(record_id,name,entity_type_id) VALUES (?1,?2,?3)
132        ON CONFLICT(record_id) DO UPDATE SET name=excluded.name,entity_type_id=excluded.entity_type_id",
133        params![header.id, input.name, entity_type_id])?;
134    conn.execute("DELETE FROM entity_aliases WHERE entity_id=?1", [header.id])?;
135    for alias in std::iter::once(&input.name).chain(aliases.iter()) {
136        let alias_id = storage::term_id(conn, alias)?;
137        conn.execute("INSERT OR IGNORE INTO entity_aliases(entity_id,alias_id) VALUES (?1,?2)", params![header.id, alias_id])?;
138    }
139    conn.execute("DELETE FROM entity_attributes WHERE entity_id=?1", [header.id])?;
140    for (key, values) in &attributes {
141        let key_id = storage::term_id(conn, key)?;
142        for value in values {
143            conn.execute("INSERT OR IGNORE INTO entity_attributes(entity_id,attr_key_id,attr_value) VALUES (?1,?2,?3)", params![header.id, key_id, value])?;
144        }
145    }
146    Ok((Entity { header, name: input.name.clone(), entity_type: input.entity_type.clone(), aliases, attributes, summary: input.summary.clone() }, document))
147}
148
149pub(crate) fn upsert_relation(conn: &Connection, input: &RelationInput) -> Result<(Relation, crate::index::IndexDocument)> {
150    storage::validate_identity("predicate", &input.predicate)?;
151    check_confidence(input.confidence)?;
152    let subject = referenced_entity(conn, &input.record, input.subject_id)?;
153    let object = referenced_entity(conn, &input.record, input.object_id)?;
154    let body = format!("{} {} {} {}", subject.name, input.predicate, object.name, input.reason);
155    let (header, document) = storage::put_record(conn, RecordKind::Relation, &input.record,
156        &json!({"subject_id":input.subject_id,"predicate":input.predicate,"object_id":input.object_id,"confidence":input.confidence,"reason":input.reason,
157            "subject_name":subject.name,"object_name":object.name}), &body)?;
158    let predicate_id = storage::term_id(conn, &input.predicate)?;
159    conn.execute("INSERT INTO relations(record_id,subject_id,predicate_id,object_id) VALUES (?1,?2,?3,?4)
160        ON CONFLICT(record_id) DO UPDATE SET subject_id=excluded.subject_id,predicate_id=excluded.predicate_id,object_id=excluded.object_id",
161        params![header.id, input.subject_id, predicate_id, input.object_id])?;
162    Ok((Relation { header, subject_id: input.subject_id, predicate: input.predicate.clone(), object_id: input.object_id, confidence: input.confidence, reason: input.reason.clone() }, document))
163}
164
165pub(crate) fn upsert_event(conn: &Connection, input: &EventInput) -> Result<(Event, crate::index::IndexDocument)> {
166    storage::validate_identity("event name", &input.name)?;
167    check_confidence(input.confidence)?;
168    let participants: Vec<_> = input.participants.iter().copied().collect::<BTreeSet<_>>().into_iter().collect();
169    let names = participants.iter().map(|id| referenced_entity(conn, &input.record, *id).map(|e| e.name)).collect::<Result<Vec<_>>>()?;
170    let body = format!("{} {} {} {}", input.name, input.summary, names.join(" "), input.reason);
171    let (header, document) = storage::put_record(conn, RecordKind::Event, &input.record,
172        &json!({"name":input.name,"summary":input.summary,"participants":participants,"confidence":input.confidence,"reason":input.reason,
173            "participant_names":names}), &body)?;
174    conn.execute("DELETE FROM event_participants WHERE event_id=?1", [header.id])?;
175    for id in &participants {
176        conn.execute("INSERT INTO event_participants(event_id,entity_id) VALUES (?1,?2)", params![header.id, id])?;
177    }
178    Ok((Event { header, name: input.name.clone(), summary: input.summary.clone(), participants, confidence: input.confidence, reason: input.reason.clone() }, document))
179}
180
181pub(crate) fn apply_batch(conn: &Connection, batch: &GraphBatch) -> Result<(GraphBatchResult, Vec<crate::index::IndexDocument>)> {
182    let mut documents = Vec::new();
183    // Entities first permits references to entities created in this transaction.
184    let mut entities = Vec::new();
185    for input in &batch.entities { let (entity, document) = upsert_entity(conn, input)?; entities.push(entity); documents.push(document); }
186    let mut relations = Vec::new();
187    for input in &batch.relations { let (relation, document) = upsert_relation(conn, input)?; relations.push(relation); documents.push(document); }
188    let mut events = Vec::new();
189    for input in &batch.events { let (event, document) = upsert_event(conn, input)?; events.push(event); documents.push(document); }
190    // 改名会改写引用它的关系与事件正文,这些记录的索引与向量都要一并刷新。
191    documents.extend(refresh_dependents(conn, &entities)?);
192    Ok((GraphBatchResult { entities, relations, events }, documents))
193}
194
195/// 返回因引用正文变化而被重写的记录的索引文档。
196fn refresh_dependents(conn: &Connection, entities: &[Entity]) -> Result<Vec<crate::index::IndexDocument>> {
197    let mut keys = BTreeSet::new();
198    let mut documents = Vec::new();
199    for entity in entities {
200        let mut stmt = conn.prepare("SELECT record_id FROM relations WHERE subject_id=?1 OR object_id=?1
201            UNION SELECT event_id FROM event_participants WHERE entity_id=?1")?;
202        for row in stmt.query_map([entity.header.id], |r| r.get::<_, i64>(0))? { keys.insert(RecordKey { id: row? }); }
203    }
204    for key in keys {
205        let value = storage::record_value(conn, &key)?.ok_or_else(|| Error::NotFound(key.id.to_string()))?;
206        let kind_code: i64 = conn.query_row("SELECT kind FROM records WHERE id=?1", [key.id], |r| r.get(0))?;
207        let kind = RecordKind::from_code(kind_code).ok_or_else(|| Error::Validation("invalid stored record kind".into()))?;
208        // 正文由 payload 字段现拼;实体改名时,把新名字写回 payload 的名称快照并推进指纹。
209        let (body, patch) = if kind == RecordKind::Relation {
210            let r: Relation = serde_json::from_value(value.clone())?;
211            let input = r.header.as_input();
212            let subject = referenced_entity(conn, &input, r.subject_id)?.name;
213            let object = referenced_entity(conn, &input, r.object_id)?.name;
214            (format!("{} {} {} {}", subject, r.predicate, object, r.reason), json!({"subject_name":subject,"object_name":object}))
215        } else {
216            let e: Event = serde_json::from_value(value.clone())?;
217            let names = e.participants.iter().map(|id| referenced_entity(conn, &e.header.as_input(), *id).map(|v| v.name)).collect::<Result<Vec<_>>>()?;
218            (format!("{} {} {} {}", e.name, e.summary, names.join(" "), e.reason), json!({"participant_names":names}))
219        };
220        let old = storage::record_text(kind, &value);
221        if old != body {
222            let raw: String = conn.query_row("SELECT payload_json FROM records WHERE id=?1", [key.id], |r| r.get(0))?;
223            let mut payload: serde_json::Value = serde_json::from_str(&raw)?;
224            if let Some(object) = payload.as_object_mut() {
225                if let Some(extra) = patch.as_object() {
226                    for (name, value) in extra { object.insert(name.clone(), value.clone()); }
227                }
228            }
229            let tags: Vec<String> = value.get("tags").and_then(|v| v.as_array())
230                .map(|list| list.iter().filter_map(|v| v.as_str()).map(str::to_string).collect()).unwrap_or_default();
231            let fingerprint = storage::record_fingerprint(&body, &tags);
232            let revision = storage::next_revision(conn, key.id)?;
233            conn.execute("UPDATE records SET payload_json=?2,fingerprint=?3,revision=?4,updated_at_us=MAX(updated_at_us,?5) WHERE id=?1",
234                params![key.id, serde_json::to_string(&payload)?, fingerprint, revision, storage::now_us()])?;
235            conn.execute("DELETE FROM embeddings WHERE record_id=?1", [key.id])?;
236            // 正文与向量都改了,这个领域的向量分区跟着变。
237            storage::touch_record_namespace(conn, key.id)?;
238            documents.push(storage::index_document(conn, key.id, kind, body.clone())?);
239        }
240    }
241    Ok(documents)
242}
243
244impl GraphStore {
245    pub fn apply_batch(&self, batch: &GraphBatch) -> Result<WriteReceipt<GraphBatchResult>> {
246        let mut documents: Vec<crate::index::IndexDocument> = Vec::new();
247        let receipt = self.0.mutate(|tx| {
248            let (result, staged) = apply_batch(tx, batch)?;
249            documents = staged;
250            Ok(result)
251        })?;
252        self.0.index_documents(&documents)?;
253        Ok(receipt)
254    }
255    pub fn get(&self, kind: RecordKind, id: i64, filter: &ReadFilter) -> Result<serde_json::Value> {
256        if !matches!(kind, RecordKind::Entity | RecordKind::Relation | RecordKind::Event) { return Err(Error::Validation("expected a graph record kind".into())); }
257        storage::get(self.0.read()?.conn(), &RecordKey { id }, filter)
258    }
259    pub fn list(&self, kind: RecordKind, page: &PageRequest) -> Result<Page<serde_json::Value>> {
260        if !matches!(kind, RecordKind::Entity | RecordKind::Relation | RecordKind::Event) { return Err(Error::Validation("expected a graph record kind".into())); }
261        storage::list(self.0.read()?.conn(), kind, page)
262    }
263    /// 登记一批谓词等价组到某个知识领域:`groups` 是一组组互等同义词,
264    /// 组内第一个词当规范词(组内代表),同组词据此归并。重复登记同一个词会改写它的归属。
265    /// 表由上游提供、库不内置领域数据;只影响查询期扩散,不改谓词的落盘写法。
266    pub fn set_predicate_equivalents(&self, namespace: &str, groups: &[Vec<String>]) -> Result<WriteReceipt<usize>> {
267        storage::validate_identity("namespace", namespace)?;
268        for group in groups {
269            if group.is_empty() { return Err(Error::Validation("an equivalent group must not be empty".into())); }
270            for term in group { storage::validate_identity("predicate", term)?; }
271        }
272        self.0.mutate_meta(|tx| {
273            let namespace_id = storage::term_id(tx, namespace)?;
274            let mut count = 0usize;
275            for group in groups {
276                let canonical_id = storage::term_id(tx, &group[0])?;
277                for term in group {
278                    let predicate_id = storage::term_id(tx, term)?;
279                    tx.execute("INSERT INTO predicate_equivalents(namespace_id,predicate_id,canonical_id) VALUES (?1,?2,?3) \
280                        ON CONFLICT(namespace_id,predicate_id) DO UPDATE SET canonical_id=excluded.canonical_id",
281                        params![namespace_id, predicate_id, canonical_id])?;
282                    count += 1;
283                }
284            }
285            Ok(count)
286        })
287    }
288
289    /// 列出某个知识领域已登记的等价组;每个组是一组互等同义词。没登记过就返回空。
290    pub fn predicate_equivalents(&self, namespace: &str) -> Result<Vec<Vec<String>>> {
291        storage::validate_identity("namespace", namespace)?;
292        let state = self.0.read()?;
293        let conn = state.conn();
294        let Some(namespace_id) = namespace_term(conn, namespace)? else { return Ok(Vec::new()); };
295        let mut stmt = conn.prepare("SELECT pe.canonical_id, s.text FROM predicate_equivalents pe \
296            JOIN strings s ON s.id=pe.predicate_id WHERE pe.namespace_id=?1 ORDER BY pe.canonical_id, s.text")?;
297        let mut groups: BTreeMap<i64, Vec<String>> = BTreeMap::new();
298        for row in stmt.query_map([namespace_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
299            let (canonical_id, term) = row?;
300            groups.entry(canonical_id).or_default().push(term);
301        }
302        Ok(groups.into_values().map(|mut group| { group.sort(); group.dedup(); group }).collect())
303    }
304
305    /// 扩散:找出 `text` 里出现了哪些已登记谓词,返回这些谓词所在等价组的全部同义词。
306    /// 命中判定是「登记词作为子串出现在查询里」。
307    /// 返回的是可直接追加进检索的补充词元;没有命中返回空。
308    pub fn expand_query(&self, namespace: &str, text_value: &str) -> Result<Vec<String>> {
309        storage::validate_identity("namespace", namespace)?;
310        expand_query_conn(self.0.read()?.conn(), namespace, text_value)
311    }
312
313    pub fn resolve(&self, name: &str, filter: &ReadFilter, limit: usize) -> Result<Vec<Entity>> {
314        storage::validate_filter(filter)?; storage::validate_limit(limit)?;
315        let state = self.0.read()?;
316        let conn = state.conn();
317        let mut stmt = conn.prepare("SELECT DISTINCT entity_id FROM entity_aliases WHERE alias_id=(SELECT id FROM strings WHERE text=?1) ORDER BY entity_id")?;
318        let mut result = Vec::new();
319        for row in stmt.query_map([text::normalized_tag(name)], |r| r.get::<_, i64>(0))? {
320            let key = RecordKey { id: row? };
321            if storage::matches_filter(conn, &key, filter)? {
322                result.push(storage::get(conn, &key, filter)?);
323                if result.len() == limit { break; }
324            }
325        }
326        Ok(result)
327    }
328    pub fn neighbors(&self, id: i64, filter: &ReadFilter, limit: usize) -> Result<Neighborhood> {
329        storage::validate_limit(limit)?;
330        let state = self.0.read()?;
331        let conn = state.conn();
332        let root = RecordKey { id };
333        let _: Entity = storage::get(conn, &root, filter)?;
334        let mut stmt = conn.prepare("SELECT record_id FROM relations WHERE subject_id=?1 OR object_id=?1 ORDER BY record_id")?;
335        let mut entities = BTreeMap::new(); let mut relations = Vec::new();
336        for row in stmt.query_map([id], |r| r.get::<_, i64>(0))? {
337            let key = RecordKey { id: row? };
338            if !storage::matches_filter(conn, &key, filter)? { continue; }
339            let relation: Relation = storage::get(conn, &key, filter)?;
340            let endpoint = if relation.subject_id == id { relation.object_id } else { relation.subject_id };
341            let entity_key = RecordKey { id: endpoint };
342            // Tags constrain returned relations, while scope constrains endpoints too.
343            let entity_filter = ReadFilter { tags: vec![], ..filter.clone() };
344            if !storage::matches_filter(conn, &entity_key, &entity_filter)? { continue; }
345            entities.insert(endpoint, storage::get(conn, &entity_key, &entity_filter)?);
346            relations.push(relation);
347            if relations.len() == limit { break; }
348        }
349        Ok(Neighborhood { entities: entities.into_values().collect(), relations })
350    }
351    pub fn events_for_entity(&self, id: i64, filter: &ReadFilter, limit: usize) -> Result<Vec<Event>> {
352        storage::validate_limit(limit)?;
353        let state = self.0.read()?;
354        let conn = state.conn();
355        let _: Entity = storage::get(conn, &RecordKey { id }, &ReadFilter { tags: vec![], ..filter.clone() })?;
356        let mut stmt = conn.prepare("SELECT event_id FROM event_participants WHERE entity_id=?1 ORDER BY event_id")?;
357        let mut events = Vec::new();
358        for row in stmt.query_map([id], |r| r.get::<_, i64>(0))? {
359            let key = RecordKey { id: row? };
360            if storage::matches_filter(conn, &key, filter)? { events.push(storage::get(conn, &key, filter)?); }
361            if events.len() == limit { break; }
362        }
363        Ok(events)
364    }
365    pub fn delete(&self, kind: RecordKind, id: i64, filter: &ReadFilter) -> Result<WriteReceipt<bool>> {
366        if !matches!(kind, RecordKind::Entity | RecordKind::Relation | RecordKind::Event) { return Err(Error::Validation("expected a graph record kind".into())); }
367        self.0.mutate(|tx| {
368            let key = RecordKey { id };
369            if !storage::matches_filter(tx, &key, filter)? { return Err(Error::NotFound(id.to_string())); }
370            storage::delete_record(tx, &key)
371        })
372    }
373    /// 删除过滤条件命中的全部图记录(实体、关系、事件),返回删除条数。
374    ///
375    /// 顺序固定为**关系 → 事件 → 实体**:关系与事件的参与行都以 RESTRICT 引用实体,
376    /// 先删边再删点,级联顺序由这里保证,调用方不必知道外键怎么连。
377    /// 若某个待删实体仍被过滤条件之外的关系或事件引用,删除会被外键拦下并返回 `Conflict`,
378    /// 整个事务回滚,不做部分删除。
379    pub fn delete_by_filter(&self, filter: &ReadFilter) -> Result<WriteReceipt<usize>> {
380        self.0.mutate(|tx| {
381            let mut removed = 0;
382            for kind in [RecordKind::Relation, RecordKind::Event, RecordKind::Entity] {
383                for id in storage::select_ids(tx, filter, &[kind])? {
384                    if storage::delete_record(tx, &RecordKey { id })? { removed += 1; }
385                }
386            }
387            Ok(removed)
388        })
389    }
390}