velesdb_memory/storage.rs
1//! Storage backend abstraction for [`crate::service::MemoryService`].
2//!
3//! The wedge orchestration (remember/recall/relate/forget/why/fusion) is
4//! written once, generic over [`MemoryStore`], so it runs unchanged over any
5//! backend: the native, file-backed [`NativeStore`] (the default — nothing
6//! changes for existing callers), or an in-memory backend such as the one
7//! `velesdb-wasm` provides for the browser (no filesystem, no `persistence`
8//! feature).
9
10#[cfg(feature = "persistence")]
11use std::collections::HashMap;
12#[cfg(feature = "persistence")]
13use std::path::Path;
14#[cfg(feature = "persistence")]
15use std::sync::Arc;
16
17#[cfg(feature = "persistence")]
18use serde_json::json;
19use serde_json::Value;
20#[cfg(feature = "persistence")]
21use velesdb_core::agent::AgentMemory;
22#[cfg(feature = "persistence")]
23use velesdb_core::{Database, SearchResult};
24
25use crate::error::MemoryError;
26use crate::model::{BoundedMemoryEdges, ColumnFilter, MemoryEdge, Recollection};
27#[cfg(feature = "persistence")]
28use crate::mutation::{DirtyKey, MutationCapture, MutationObserver};
29use crate::service::Metadata;
30
31#[cfg(feature = "persistence")]
32mod migration;
33
34/// Fact storage: write, by-id lookup, deletion, corpus size — the core
35/// facet every backend must provide. The other facets ([`RecallStore`],
36/// [`GraphStore`], [`ColumnStore`]) build on stored facts; a partial
37/// backend, or a test double, implements only the facets it serves and
38/// the compiler refuses calls to the rest (#1959).
39pub trait FactStore {
40 /// Store a fact with no metadata or expiry.
41 ///
42 /// # Errors
43 /// Returns [`MemoryError`] if persistence fails.
44 fn store(&self, id: u64, content: &str, embedding: &[f32]) -> Result<(), MemoryError>;
45
46 /// Store a fact tagged with `metadata`, no expiry.
47 ///
48 /// # Errors
49 /// Returns [`MemoryError`] if persistence fails.
50 fn store_with_metadata(
51 &self,
52 id: u64,
53 content: &str,
54 embedding: &[f32],
55 metadata: &Metadata,
56 ) -> Result<(), MemoryError>;
57
58 /// Store a fact that expires after `ttl_seconds`, no metadata.
59 ///
60 /// # Errors
61 /// Returns [`MemoryError`] if persistence fails.
62 fn store_with_ttl(
63 &self,
64 id: u64,
65 content: &str,
66 embedding: &[f32],
67 ttl_seconds: u64,
68 ) -> Result<(), MemoryError>;
69
70 /// Store a fact with BOTH metadata and a durable TTL, in ONE write.
71 ///
72 /// Default: the historical two-call sequence, so a backend written before
73 /// this method keeps compiling and behaving as it did. Backends that can
74 /// write both at once should override it — the two-call form leaves the
75 /// fact live and expiring between the calls, so a short TTL can lapse in
76 /// the gap and the metadata write then fails on a fact that was perfectly
77 /// valid when the caller asked for it.
78 ///
79 /// # Errors
80 /// Returns [`MemoryError`] if persistence fails.
81 fn store_with_metadata_and_ttl(
82 &self,
83 id: u64,
84 content: &str,
85 embedding: &[f32],
86 metadata: &Metadata,
87 ttl_seconds: u64,
88 ) -> Result<(), MemoryError> {
89 self.store_with_ttl(id, content, embedding, ttl_seconds)?;
90 self.update_metadata(id, metadata)
91 }
92
93 /// Merge `metadata` into an already-stored fact's payload, preserving any
94 /// durable TTL. Used to combine metadata with an expiry (store both in
95 /// two calls rather than needing every metadata×TTL combination as a
96 /// separate primitive).
97 ///
98 /// # Errors
99 /// Returns [`MemoryError`] if `id` is unknown or persistence fails.
100 fn update_metadata(&self, id: u64, metadata: &Metadata) -> Result<(), MemoryError>;
101
102 /// A fact's content and embedding, or `None` if unknown/expired.
103 ///
104 /// # Errors
105 /// Returns [`MemoryError`] if storage access fails.
106 fn get(&self, id: u64) -> Result<Option<(String, Vec<f32>)>, MemoryError>;
107
108 /// A fact's raw stored payload — reserved system keys (`_veles_*`)
109 /// included, so the service layer can check the hub flag before
110 /// stripping them for the caller — or `None` when the fact is
111 /// unknown/expired.
112 ///
113 /// # Errors
114 /// Returns [`MemoryError`] if storage access fails.
115 fn get_metadata(&self, id: u64) -> Result<Option<Metadata>, MemoryError>;
116
117 /// Batched [`Self::get_metadata`]: one storage round trip for every id
118 /// in `ids`, results in the same order and length (an unknown or expired
119 /// id maps to `None`). Same raw-payload semantics as the single-id form.
120 ///
121 /// # Errors
122 /// Returns [`MemoryError`] if storage access fails.
123 fn get_metadata_batch(&self, ids: &[u64]) -> Result<Vec<Option<Metadata>>, MemoryError>;
124
125 /// Delete a fact.
126 ///
127 /// # Errors
128 /// Returns [`MemoryError`] if deletion fails.
129 fn delete(&self, id: u64) -> Result<(), MemoryError>;
130
131 /// The total number of live (non-expired) tracked facts, including
132 /// internal entity hubs — used as a corpus-size proxy for idf weighting.
133 fn count(&self) -> usize;
134
135 /// One cursor page of the store's live facts, ids ascending: up to
136 /// `limit` entries strictly after `cursor` (`None` starts the walk),
137 /// plus the cursor for the next page (`None` ends it). Payloads come
138 /// back RAW — reserved keys and scaffolding markers included — because
139 /// the policy of what a caller may see (hub filtering, key stripping)
140 /// belongs to the service layer, in one place, for every backend.
141 ///
142 /// TTL-expired facts are skipped, not listed: an audit must show what
143 /// the store will still serve, and a fact past its expiry is not it.
144 ///
145 /// Defaulted to a refusal rather than required, same reasoning as
146 /// [`GraphStore::edge_count`]: an out-of-crate backend keeps compiling,
147 /// and its `list_memories` answers with this error instead of a wrong
148 /// walk.
149 ///
150 /// # Errors
151 /// Returns [`MemoryError::Unsupported`] if the backend cannot enumerate
152 /// at all, or [`MemoryError`] if the walk fails.
153 fn list(
154 &self,
155 cursor: Option<u64>,
156 limit: usize,
157 ) -> Result<(Vec<RawListedFact>, Option<u64>), MemoryError> {
158 let _ = (cursor, limit);
159 Err(MemoryError::Unsupported(
160 "this storage backend does not support listing",
161 ))
162 }
163}
164
165/// Vector recall over stored facts — the surface every
166/// [`crate::service::MemoryService::recall`]/`search` call goes through.
167/// [`FactStore`] is a supertrait because these queries return the content
168/// of the facts they rank; a backend cannot rank what it cannot store.
169pub trait RecallStore: FactStore {
170 /// Vector search for up to `k` ids, narrowed to facts whose metadata
171 /// exactly matches every key in `filter`.
172 ///
173 /// # Errors
174 /// Returns [`MemoryError`] if the query fails.
175 fn query_filtered(
176 &self,
177 embedding: &[f32],
178 k: usize,
179 filter: &Metadata,
180 offset: usize,
181 ) -> Result<Vec<(u64, f32, String)>, MemoryError>;
182
183 /// Vector search for up to `k` ids, dropping facts whose metadata matches
184 /// every key in `exclude`.
185 ///
186 /// # Errors
187 /// Returns [`MemoryError`] if the query fails.
188 fn query_excluding(
189 &self,
190 embedding: &[f32],
191 k: usize,
192 exclude: &Metadata,
193 ) -> Result<Vec<(u64, f32, String)>, MemoryError>;
194}
195
196/// Structured columnar predicates fused with vector recall — one method
197/// today, but the facet where field enumeration and richer predicates will
198/// land ([`ColumnFilter`]'s op set is already `non_exhaustive`).
199pub trait ColumnStore {
200 /// Vector search fused with structured columnar predicates (ranges
201 /// and comparisons, not just equality) — the engine behind
202 /// [`crate::service::MemoryService::recall_where`].
203 ///
204 /// # Absent and null fields
205 ///
206 /// **A filter is satisfied only by a fact that HAS the field with a
207 /// non-null value.** A fact missing the field, or storing `null` in it, is
208 /// never returned — and `ne` is no exception, exactly as a SQL comparison
209 /// against `NULL` is never true.
210 ///
211 /// | field state | `field != target` | `field == target` | `<` `<=` `>` `>=` |
212 /// |---|---|---|---|
213 /// | absent | no match | no match | no match |
214 /// | present, `null` | no match | no match | no match |
215 /// | present, equal | no match | match | per the comparison |
216 /// | present, different | **match** | no match | per the comparison |
217 ///
218 /// This is stated because it did not hold: `ne` on an absent field matched
219 /// on the native backend and never matched on WASM, for the API's whole
220 /// life, because nothing compared them (#1759). Every backend is now held
221 /// to one shared table —
222 /// [`crate::column_filter_conformance`] — run against both.
223 ///
224 /// Null-ness is not expressible through [`ColumnFilter`]; querying for it
225 /// is what `IsNull`/`IsNotNull` are for at the `VelesQL` layer.
226 ///
227 /// # Errors
228 /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a
229 /// plain identifier or a filter value is non-scalar, or [`MemoryError`]
230 /// if the query fails.
231 fn query_columnar(
232 &self,
233 embedding: &[f32],
234 k: usize,
235 filters: &[ColumnFilter],
236 ) -> Result<Vec<Recollection>, MemoryError>;
237}
238
239/// Typed graph edges between facts — the facet behind `relate`/`why` and
240/// the hub walks. A backend without a graph simply does not implement it,
241/// and the service methods that need it stop existing for that backend at
242/// compile time.
243pub trait GraphStore {
244 /// Create a typed edge `from -> to`. Returns the edge id.
245 ///
246 /// # Errors
247 /// Returns [`MemoryError`] if either endpoint is missing or persistence fails.
248 fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError>;
249
250 /// The outgoing edges of `id`.
251 ///
252 /// # Errors
253 /// Returns [`MemoryError`] if storage access fails.
254 fn relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError>;
255
256 /// The incoming edges of `id` — the mirror of [`Self::relations`], with
257 /// the same liveness rule applied to the far end (here the *source*).
258 ///
259 /// # Errors
260 /// Returns [`MemoryError`] if storage access fails.
261 fn incoming_relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError>;
262
263 /// At most `cap` outgoing edges of `id`, plus whether its total degree
264 /// exceeded the scan — the bounded twin of [`Self::relations`] (#1820).
265 ///
266 /// The contract is on COST, not just shape: an implementation must keep
267 /// work and transient allocation O(cap), never O(degree) — a super-node
268 /// (an entity hub mentioned by thousands of facts) is exactly where this
269 /// accessor is reached for. `truncated` is a separate signal because
270 /// `edges.len() == cap` cannot carry it: a node with exactly `cap` edges
271 /// is indistinguishable from a truncated one.
272 ///
273 /// # Errors
274 /// Returns [`MemoryError`] if storage access fails.
275 fn relations_bounded(&self, id: u64, cap: usize) -> Result<BoundedMemoryEdges, MemoryError>;
276
277 /// At most `cap` incoming edges of `id`, plus whether its total incoming
278 /// degree exceeded the scan — the mirror of [`Self::relations_bounded`],
279 /// same O(cap) cost contract.
280 ///
281 /// # Errors
282 /// Returns [`MemoryError`] if storage access fails.
283 fn incoming_relations_bounded(
284 &self,
285 id: u64,
286 cap: usize,
287 ) -> Result<BoundedMemoryEdges, MemoryError>;
288
289 /// Remove the edge with `edge_id`. Returns `true` when it existed —
290 /// idempotent: removing an absent edge is `Ok(false)`, never an error.
291 ///
292 /// # Errors
293 /// Returns [`MemoryError`] if storage access fails.
294 fn unrelate(&self, edge_id: u64) -> Result<bool, MemoryError>;
295
296 /// Remove an edge while preserving its known source for mutation capture.
297 ///
298 /// The default keeps third-party backends source-compatible. Native
299 /// online migration overrides it so `OutgoingEdges(from)` is recorded
300 /// before the edge is removed.
301 ///
302 /// # Errors
303 /// Returns [`MemoryError`] if storage access fails.
304 fn unrelate_from(&self, from: u64, edge_id: u64) -> Result<bool, MemoryError> {
305 let _ = from;
306 self.unrelate(edge_id)
307 }
308
309 /// The total number of graph edges, when the backend can answer without
310 /// materializing them — the observable difference between a store whose
311 /// `why()` can walk somewhere and one where it degrades to plain
312 /// similarity search.
313 ///
314 /// Defaulted to `None` ("cannot say") rather than required, deliberately:
315 /// a backend outside this crate (velesdb-wasm's in-memory store) must
316 /// keep compiling when this surface grows, and a wrong-but-cheap answer
317 /// here would flag healthy graphs as flat. `memory_status` reports the
318 /// distinction to the caller instead of papering over it.
319 fn edge_count(&self) -> Option<usize> {
320 None
321 }
322}
323
324/// The full storage surface [`crate::service::MemoryService`] historically
325/// required: every facet at once. Kept as a supertrait alias so existing
326/// callers and bounds (`S: MemoryStore`) compile unchanged; the blanket
327/// impl makes it automatic for any backend that implements the facets, so
328/// there is nothing extra to implement and nothing to forget.
329///
330/// Implementors migrating from the pre-facet monolith (≤ 0.13): the
331/// methods did not change — they moved. `impl MemoryStore` becomes
332/// `impl FactStore + RecallStore + GraphStore + ColumnStore` (#1959).
333pub trait MemoryStore: RecallStore + GraphStore + ColumnStore {}
334
335impl<T: RecallStore + GraphStore + ColumnStore> MemoryStore for T {}
336
337/// One fact as [`FactStore::list`] hands it to the service layer: content
338/// split out, everything else — reserved keys and scaffolding markers
339/// included — still in `payload` so the service can apply its visibility
340/// policy exactly once for every backend.
341#[derive(Debug, Clone)]
342pub struct RawListedFact {
343 /// Stable id of the fact.
344 pub id: u64,
345 /// The stored fact text (the payload's `content` key).
346 pub content: String,
347 /// The rest of the stored payload, verbatim.
348 pub payload: Metadata,
349}
350
351#[cfg(feature = "persistence")]
352impl RawListedFact {
353 /// The one place a stored payload is split into content + the rest —
354 /// shared by [`MemoryStore::list`] and the JSONL export so the two
355 /// reading surfaces can never disagree on what a fact's content IS.
356 pub(crate) fn from_raw(fact: &crate::migration::RawFact) -> Self {
357 let mut payload: Metadata = serde_json::from_str(&fact.payload).unwrap_or_default();
358 let content = match payload.remove("content") {
359 Some(Value::String(text)) => text,
360 _ => String::new(),
361 };
362 Self {
363 id: fact.id,
364 content,
365 payload,
366 }
367 }
368}
369
370/// The default [`MemoryStore`]: the native, file-backed engine
371/// (`velesdb-core`'s `Database`/`AgentMemory`, requiring the `persistence`
372/// feature). Existing callers of `MemoryService::open` see no change — this
373/// is exactly what they already ran.
374#[cfg(feature = "persistence")]
375pub struct NativeStore {
376 memory: AgentMemory,
377 /// Kept beside `memory` (which owns its own clone) for the read paths
378 /// that speak to the engine directly — [`MemoryStore::list`] walks the
379 /// collection cursor, which `AgentMemory` does not re-expose.
380 db: Arc<Database>,
381 capture: MutationCapture,
382}
383
384#[cfg(feature = "persistence")]
385impl NativeStore {
386 /// Open (or create) a native store at `path`, sized for `dimension`.
387 ///
388 /// # Errors
389 /// Returns [`MemoryError`] if the store cannot be opened.
390 pub fn open<P: AsRef<Path>>(path: P, dimension: usize) -> Result<Self, MemoryError> {
391 let db = Arc::new(Database::open(path)?);
392 let memory = AgentMemory::with_dimension(Arc::clone(&db), dimension)?;
393 Ok(Self {
394 memory,
395 db,
396 capture: MutationCapture::default(),
397 })
398 }
399
400 pub(crate) fn set_mutation_observer(
401 &self,
402 observer: Option<Arc<dyn MutationObserver>>,
403 ) -> Result<(), MemoryError> {
404 self.capture.replace(observer)
405 }
406
407 pub(crate) fn mutation_capture_active(&self) -> bool {
408 self.capture.is_active()
409 }
410
411 fn unrelate_unobserved(&self, edge_id: u64) -> Result<bool, MemoryError> {
412 self.memory
413 .semantic()
414 .unrelate(edge_id)
415 .map_err(MemoryError::from)
416 }
417}
418
419#[cfg(feature = "persistence")]
420impl FactStore for NativeStore {
421 fn store(&self, id: u64, content: &str, embedding: &[f32]) -> Result<(), MemoryError> {
422 self.capture.observe(DirtyKey::Fact(id))?;
423 self.memory
424 .semantic()
425 .store(id, content, embedding)
426 .map_err(MemoryError::from)
427 }
428
429 fn store_with_metadata(
430 &self,
431 id: u64,
432 content: &str,
433 embedding: &[f32],
434 metadata: &Metadata,
435 ) -> Result<(), MemoryError> {
436 self.capture.observe(DirtyKey::Fact(id))?;
437 self.memory
438 .semantic()
439 .store_with_metadata(id, content, embedding, metadata)
440 .map_err(MemoryError::from)
441 }
442
443 fn store_with_ttl(
444 &self,
445 id: u64,
446 content: &str,
447 embedding: &[f32],
448 ttl_seconds: u64,
449 ) -> Result<(), MemoryError> {
450 self.capture.observe(DirtyKey::Fact(id))?;
451 self.memory
452 .semantic()
453 .store_with_ttl(id, content, embedding, ttl_seconds)
454 .map_err(MemoryError::from)
455 }
456
457 fn update_metadata(&self, id: u64, metadata: &Metadata) -> Result<(), MemoryError> {
458 self.capture.observe(DirtyKey::Fact(id))?;
459 self.memory
460 .semantic()
461 .update_metadata(id, metadata)
462 .map_err(MemoryError::from)
463 }
464
465 fn store_with_metadata_and_ttl(
466 &self,
467 id: u64,
468 content: &str,
469 embedding: &[f32],
470 metadata: &Metadata,
471 ttl_seconds: u64,
472 ) -> Result<(), MemoryError> {
473 self.capture.observe(DirtyKey::Fact(id))?;
474 // Ordre delibere : le fait est ecrit avec sa metadata et SANS
475 // expiration, donc il ne peut pas expirer entre les deux appels.
476 // L'expiration est posee ensuite. C'est l'inverse de la sequence
477 // historique (store_with_ttl puis update_metadata), ou le fait etait
478 // deja vivant et deja en train d'expirer pendant la seconde ecriture.
479 self.memory
480 .semantic()
481 .store_with_metadata(id, content, embedding, metadata)
482 .map_err(MemoryError::from)?;
483 self.memory
484 .semantic()
485 .set_ttl_durable(id, ttl_seconds)
486 .map_err(MemoryError::from)
487 }
488
489 fn get(&self, id: u64) -> Result<Option<(String, Vec<f32>)>, MemoryError> {
490 self.memory.semantic().get(id).map_err(MemoryError::from)
491 }
492
493 fn get_metadata(&self, id: u64) -> Result<Option<Metadata>, MemoryError> {
494 self.memory
495 .semantic()
496 .get_metadata(id)
497 .map_err(MemoryError::from)
498 }
499
500 fn get_metadata_batch(&self, ids: &[u64]) -> Result<Vec<Option<Metadata>>, MemoryError> {
501 self.memory
502 .semantic()
503 .get_metadata_batch(ids)
504 .map_err(MemoryError::from)
505 }
506
507 fn delete(&self, id: u64) -> Result<(), MemoryError> {
508 self.capture.observe(DirtyKey::Fact(id))?;
509 self.memory.semantic().delete(id).map_err(MemoryError::from)
510 }
511
512 fn count(&self) -> usize {
513 self.memory.semantic().count()
514 }
515
516 fn list(
517 &self,
518 cursor: Option<u64>,
519 limit: usize,
520 ) -> Result<(Vec<RawListedFact>, Option<u64>), MemoryError> {
521 // The migration module's cursor walk, reused verbatim: id-keyed,
522 // ascending, exclusive, and it skips TTL-expired points — exactly
523 // the audit contract (#1762 built it to enumerate a store with full
524 // fidelity, which is what an audit is).
525 let (facts, next) = crate::migration::scroll_page(
526 &self.db,
527 self.memory.semantic().collection_name(),
528 cursor,
529 limit,
530 )?;
531 let listed = facts.iter().map(RawListedFact::from_raw).collect();
532 Ok((listed, next))
533 }
534}
535
536#[cfg(feature = "persistence")]
537impl RecallStore for NativeStore {
538 fn query_filtered(
539 &self,
540 embedding: &[f32],
541 k: usize,
542 filter: &Metadata,
543 offset: usize,
544 ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
545 self.memory
546 .semantic()
547 .query_filtered(embedding, k, filter, offset)
548 .map_err(MemoryError::from)
549 }
550
551 fn query_excluding(
552 &self,
553 embedding: &[f32],
554 k: usize,
555 exclude: &Metadata,
556 ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
557 self.memory
558 .semantic()
559 .query_excluding(embedding, k, exclude)
560 .map_err(MemoryError::from)
561 }
562}
563
564#[cfg(feature = "persistence")]
565impl ColumnStore for NativeStore {
566 fn query_columnar(
567 &self,
568 embedding: &[f32],
569 k: usize,
570 filters: &[ColumnFilter],
571 ) -> Result<Vec<Recollection>, MemoryError> {
572 let (sql, params) = self.build_fused_query(embedding, k, filters)?;
573 // Field names are validated by `build_fused_query`; ensure each one is
574 // indexed so the planner uses a bitmap prefilter instead of an O(n)
575 // post-filter scan. Idempotent and incrementally maintained thereafter.
576 for field in filters
577 .iter()
578 .map(|filter| filter.field.as_str())
579 .chain(INTERNAL_MARKER_FIELDS.iter().copied())
580 {
581 self.memory
582 .semantic()
583 .ensure_index(field)
584 .map_err(MemoryError::from)?;
585 }
586 let results = self
587 .memory
588 .query_semantic(&sql, ¶ms)
589 .map_err(MemoryError::from)?;
590 Ok(results.iter().map(to_recollection).collect())
591 }
592}
593
594#[cfg(feature = "persistence")]
595impl GraphStore for NativeStore {
596 fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError> {
597 self.capture.observe(DirtyKey::OutgoingEdges(from))?;
598 self.memory
599 .semantic()
600 .relate(from, to, relation, None)
601 .map_err(MemoryError::from)
602 }
603
604 fn relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError> {
605 Ok(to_memory_edges(self.memory.semantic().relations(id)?))
606 }
607
608 fn incoming_relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError> {
609 Ok(to_memory_edges(
610 self.memory.semantic().incoming_relations(id)?,
611 ))
612 }
613
614 fn relations_bounded(&self, id: u64, cap: usize) -> Result<BoundedMemoryEdges, MemoryError> {
615 let bounded = self.memory.semantic().relations_bounded(id, cap)?;
616 Ok(BoundedMemoryEdges {
617 edges: to_memory_edges(bounded.edges),
618 truncated: bounded.truncated,
619 })
620 }
621
622 fn incoming_relations_bounded(
623 &self,
624 id: u64,
625 cap: usize,
626 ) -> Result<BoundedMemoryEdges, MemoryError> {
627 let bounded = self.memory.semantic().incoming_relations_bounded(id, cap)?;
628 Ok(BoundedMemoryEdges {
629 edges: to_memory_edges(bounded.edges),
630 truncated: bounded.truncated,
631 })
632 }
633
634 fn unrelate(&self, edge_id: u64) -> Result<bool, MemoryError> {
635 if self.capture.is_active() {
636 return Err(MemoryError::MigrationCapture(format!(
637 "cannot remove edge {edge_id} without its source id"
638 )));
639 }
640 self.unrelate_unobserved(edge_id)
641 }
642
643 fn unrelate_from(&self, from: u64, edge_id: u64) -> Result<bool, MemoryError> {
644 self.capture.observe(DirtyKey::OutgoingEdges(from))?;
645 self.unrelate_unobserved(edge_id)
646 }
647
648 fn edge_count(&self) -> Option<usize> {
649 // A collection-access failure here means the store is unusable for
650 // every other call too; for a status readout "cannot say" is the
651 // honest degradation, not an error path of its own.
652 self.memory.semantic().edge_count().ok()
653 }
654}
655
656/// Map core [`GraphEdge`](velesdb_core::collection::graph::GraphEdge)s to the
657/// wire-facing [`MemoryEdge`] shape — shared by both edge directions, so the
658/// two can never disagree on which endpoint or id they report.
659#[cfg(feature = "persistence")]
660fn to_memory_edges(edges: Vec<velesdb_core::collection::graph::GraphEdge>) -> Vec<MemoryEdge> {
661 edges
662 .into_iter()
663 .map(|edge| MemoryEdge {
664 id: edge.id(),
665 from: edge.source(),
666 to: edge.target(),
667 relation: edge.label().to_owned(),
668 })
669 .collect()
670}
671
672#[cfg(feature = "persistence")]
673impl NativeStore {
674 /// Build the `VelesQL` for [`Self::query_columnar`]: a `NEAR` predicate
675 /// plus one bound parameter per filter, against the semantic collection.
676 /// Filter *values* are bound as query parameters (never interpolated);
677 /// filter *field names* are validated to be plain identifiers.
678 fn build_fused_query(
679 &self,
680 embedding: &[f32],
681 k: usize,
682 filters: &[ColumnFilter],
683 ) -> Result<(String, HashMap<String, Value>), MemoryError> {
684 use std::fmt::Write as _;
685 let mut params: HashMap<String, Value> = HashMap::new();
686 params.insert("q".to_string(), json!(embedding));
687 let mut predicate = String::from("vector NEAR $q");
688 for (index, filter) in filters.iter().enumerate() {
689 validate_column_filter(filter)?;
690 let key = format!("p{index}");
691 // `ne` is spelled out rather than left to `!=` alone. Core's
692 // `Condition::Neq` is `is_none_or`, so a bare `field != $p` also
693 // matches a fact that HAS no such field — which made `ne` mean two
694 // different things on the two backends (#1759). Requiring the
695 // field first pins the published contract here, at the adapter,
696 // instead of redefining `!=` for all of `VelesQL` — that is a
697 // product-wide semantic break, deliberately out of scope.
698 //
699 // `IS NOT NULL` is false for an absent field AND for an explicit
700 // `null`, which is exactly the contract: a comparison against null
701 // is never true, as in SQL. `IsNull`/`IsNotNull` stay the operators
702 // dedicated to null-ness.
703 if matches!(filter.op, crate::model::ColumnOp::Ne) {
704 let _ = write!(predicate, " AND {} IS NOT NULL", filter.field);
705 }
706 let _ = write!(
707 predicate,
708 " AND {} {} ${key}",
709 filter.field,
710 filter.op.as_sql()
711 );
712 params.insert(key, filter.value.clone());
713 }
714 // Exclude internal scaffolding INSIDE the query rather than after it:
715 // the engine applies `LIMIT k`, so a post-filter would quietly return
716 // fewer than `k` caller facts whenever artefacts crowd the ranking.
717 //
718 // `!=` is what excludes here, and it works for the same reason the
719 // leak existed: `Condition::Neq` is `is_none_or`, so a fact that has
720 // no such column at all MATCHES. Applied to a marker, that keeps every
721 // caller fact (none carries one) and drops exactly the class that
722 // does. These names are compile-time constants, never caller input,
723 // so they go straight into the text without passing through
724 // `validate_column_filter` — whose job is to reject a CALLER filter
725 // naming a reserved key.
726 //
727 // The asymmetry with the caller loop above is DELIBERATE and load-
728 // bearing: a caller's `ne` now carries an `IS NOT NULL`, this exclusion
729 // must NOT. It depends on the absent field matching — that is how it
730 // keeps every caller fact while dropping the marked ones. Giving these
731 // markers the same treatment would exclude every caller fact instead,
732 // since none of them carries a marker column at all.
733 for (index, marker) in INTERNAL_MARKER_FIELDS.iter().enumerate() {
734 let key = format!("m{index}");
735 let _ = write!(predicate, " AND {marker} != ${key}");
736 params.insert(key, json!(true));
737 }
738 let sql = format!(
739 "SELECT * FROM {} WHERE {predicate} LIMIT {k}",
740 self.memory.semantic().collection_name()
741 );
742 Ok((sql, params))
743 }
744}
745
746/// Reserved metadata key `remember`/`remember_with_ttl` auto-stamp with
747/// today's date (a `YYYYMMDD` integer, [`crate::clock::today_ymd`]) whenever
748/// the caller didn't already set it — see
749/// [`crate::service::MemoryService::remember_with_ttl`] for the full
750/// contract. A deliberate, documented **exception** to every other
751/// `_veles_`-namespaced key: [`is_reserved_key`] still names it (so it can
752/// never be confused with an arbitrary caller field), but unlike a true
753/// system key —
754/// - a caller MAY set it explicitly (to date a fact retroactively; never
755/// overwritten once present), and
756/// - it is NOT stripped from caller-facing results, so
757/// [`crate::dated_context::format_dated_context`]'s `date_field` (wired
758/// through `recall_fused`'s `date_field` parameter) can read it back with
759/// zero caller effort.
760///
761/// `pub` (re-exported at the crate root) so every caller of `date_field`
762/// names this one string in exactly one place, not a copy-pasted literal.
763pub const AUTO_DATE_FIELD: &str = "_veles_date";
764
765/// True for metadata keys the memory layer reserves: the engine's `content`
766/// payload, and any `_veles_`-namespaced system key (durable TTL, entity
767/// hubs) — [`AUTO_DATE_FIELD`] EXCEPTED, since (unlike every other reserved
768/// key) it is caller-settable and caller-visible by design. The single
769/// source of the reserved-key contract — the service layer (reject/strip)
770/// and every backend enforce it through this one predicate.
771pub(crate) fn is_reserved_key(key: &str) -> bool {
772 key != AUTO_DATE_FIELD && (key == "content" || key.starts_with("_veles_"))
773}
774
775/// Marks an entity hub minted by `remember_extracted` — graph scaffolding,
776/// never a fact the caller stored.
777pub const HUB_FIELD: &str = "_veles_hub";
778/// Marks a compilation event recorded for `context_savings`.
779pub const CTX_EVENT_FIELD: &str = "_veles_ctx_event";
780/// Marks a stored compilation source, served back by `retrieve_context_source`.
781pub const CTX_SOURCE_FIELD: &str = "_veles_ctx_source";
782/// Marks a saved working context, served back by `load_working_context`.
783pub const CTX_WORKING_FIELD: &str = "_veles_ctx_working";
784/// Marks a project's working-context index, read by `list_working_contexts`.
785pub const CTX_WORKING_INDEX_FIELD: &str = "_veles_ctx_working_index";
786
787/// Every marker that identifies a stored fact as internal scaffolding rather
788/// than a caller memory. Facts of these five classes live in the same
789/// collection as caller facts and are written by exactly one path each; the
790/// markers are declared here, and imported by those paths, so the write and
791/// the exclusion cannot drift apart.
792///
793/// The discriminant is the PRESENCE of one of these keys — deliberately NOT
794/// the `_veles_` prefix. [`AUTO_DATE_FIELD`] (`_veles_date`) is reserved too
795/// and is stamped onto ordinary CALLER facts, so a prefix test would hide the
796/// entire store instead of the scaffolding.
797pub const INTERNAL_MARKER_FIELDS: &[&str] = &[
798 HUB_FIELD,
799 CTX_EVENT_FIELD,
800 CTX_SOURCE_FIELD,
801 CTX_WORKING_FIELD,
802 CTX_WORKING_INDEX_FIELD,
803];
804
805/// Whether a raw payload belongs to one of the five internal classes.
806///
807/// `pub` and shared for the same reason as [`validate_column_filter`]: a
808/// caller-facing recall path must not depend on which backend answered it.
809/// A backend that can test the payload directly should use this; one that
810/// pushes the predicate into a query builds the equivalent there — the
811/// authority on *which* markers count is this list either way.
812#[must_use]
813pub fn is_internal_scaffolding(payload: &Metadata) -> bool {
814 INTERNAL_MARKER_FIELDS
815 .iter()
816 .any(|marker| payload.contains_key(*marker))
817}
818
819/// Drop reserved system keys from a raw payload, and collapse an
820/// empty-after-stripping map to `None` — the caller-facing shape every
821/// [`Recollection::metadata`] is built from. `pub` because a [`MemoryStore`]
822/// backend that assembles `Recollection`s itself (`query_columnar`) must
823/// apply the same stripping the service layer applies on every other recall
824/// path, or reserved keys leak to callers on that one path only.
825#[must_use]
826pub fn strip_reserved_keys(payload: Option<Metadata>) -> Option<Metadata> {
827 payload.and_then(|payload| {
828 let metadata: Metadata = payload
829 .into_iter()
830 .filter(|(key, _)| !is_reserved_key(key))
831 .collect();
832 (!metadata.is_empty()).then_some(metadata)
833 })
834}
835
836/// [`strip_reserved_keys`] over a *borrowed* payload: clones only the
837/// surviving non-reserved entries. Use this when the payload isn't already
838/// owned — cloning the whole map first would deep-copy the reserved
839/// `content` value (the full fact text) per hit, only to discard it.
840#[must_use]
841pub fn strip_reserved_keys_ref(payload: Option<&Metadata>) -> Option<Metadata> {
842 payload.and_then(|payload| {
843 let metadata: Metadata = payload
844 .iter()
845 .filter(|(key, _)| !is_reserved_key(key))
846 .map(|(key, value)| (key.clone(), value.clone()))
847 .collect();
848 (!metadata.is_empty()).then_some(metadata)
849 })
850}
851
852/// Map a core search result to a [`Recollection`], lifting the fact text out
853/// of the reserved `content` payload key and surfacing any remaining
854/// caller-supplied metadata (reserved system keys excluded).
855#[cfg(feature = "persistence")]
856fn to_recollection(result: &SearchResult) -> Recollection {
857 let payload = result.point.payload.as_ref().and_then(Value::as_object);
858 let content = payload
859 .and_then(|payload| payload.get("content"))
860 .and_then(Value::as_str)
861 .unwrap_or_default()
862 .to_owned();
863 Recollection {
864 id: result.point.id,
865 score: result.score,
866 content,
867 metadata: strip_reserved_keys_ref(payload),
868 }
869}
870
871/// Validate one `recall_where` column filter: a plain, non-reserved
872/// identifier field name and a scalar (string/number/boolean) value. `pub`
873/// and shared so every [`MemoryStore`] backend enforces the *same* documented
874/// contract — the field-name rule keeps a filter safe to place into query
875/// text (`NativeStore` builds `VelesQL`; values are always bound parameters),
876/// and rejects the reserved system columns (`content`, `_veles_*`) regardless
877/// of backend; the scalar rule turns what would be an opaque engine error
878/// into a clear client-input error.
879///
880/// # Errors
881/// Returns [`MemoryError::InvalidFilter`] when either rule is violated.
882pub fn validate_column_filter(filter: &ColumnFilter) -> Result<(), MemoryError> {
883 let field = &filter.field;
884 let plain = !field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
885 if !plain || is_reserved_key(field) {
886 return Err(MemoryError::InvalidFilter(field.clone()));
887 }
888 match &filter.value {
889 Value::String(_) | Value::Number(_) | Value::Bool(_) => Ok(()),
890 value => Err(MemoryError::InvalidFilter(format!(
891 "value must be a string, number, or boolean, got {value}"
892 ))),
893 }
894}
895
896#[cfg(all(test, feature = "persistence"))]
897#[path = "storage_tests.rs"]
898mod tests;