Skip to main content

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).
333///
334/// Not to be confused with `velesdb-node`'s napi **class** `MemoryStore`,
335/// which is unrelated: that is the JS-facing service object (a
336/// `MemoryService` wrapper), not this storage trait. Same name, opposite
337/// sides of the binding seam (#2018).
338///
339/// Since the facet split shipped, no bound in this workspace names the
340/// alias (`S: GraphStore`-style facet bounds everywhere) — it exists only
341/// so pre-facet out-of-tree code keeps compiling, and it goes away in the
342/// next breaking cycle.
343#[deprecated(
344    since = "0.14.2",
345    note = "bound on the facet traits you use (FactStore / RecallStore / \
346            GraphStore / ColumnStore); MemoryStore is a compat alias since \
347            the 0.14 facet split and will be removed in 0.15"
348)]
349pub trait MemoryStore: RecallStore + GraphStore + ColumnStore {}
350
351// The blanket impl IS the compat shim the deprecation announces — it must
352// keep compiling until the alias is removed with it in 0.15.
353#[allow(deprecated)]
354impl<T: RecallStore + GraphStore + ColumnStore> MemoryStore for T {}
355
356/// One fact as [`FactStore::list`] hands it to the service layer: content
357/// split out, everything else — reserved keys and scaffolding markers
358/// included — still in `payload` so the service can apply its visibility
359/// policy exactly once for every backend.
360#[derive(Debug, Clone)]
361pub struct RawListedFact {
362    /// Stable id of the fact.
363    pub id: u64,
364    /// The stored fact text (the payload's `content` key).
365    pub content: String,
366    /// The rest of the stored payload, verbatim.
367    pub payload: Metadata,
368}
369
370#[cfg(feature = "persistence")]
371impl RawListedFact {
372    /// The one place a stored payload is split into content + the rest —
373    /// shared by [`MemoryStore::list`] and the JSONL export so the two
374    /// reading surfaces can never disagree on what a fact's content IS.
375    pub(crate) fn from_raw(fact: &crate::migration::RawFact) -> Self {
376        let mut payload: Metadata = serde_json::from_str(&fact.payload).unwrap_or_default();
377        let content = match payload.remove("content") {
378            Some(Value::String(text)) => text,
379            _ => String::new(),
380        };
381        Self {
382            id: fact.id,
383            content,
384            payload,
385        }
386    }
387}
388
389/// The default [`MemoryStore`]: the native, file-backed engine
390/// (`velesdb-core`'s `Database`/`AgentMemory`, requiring the `persistence`
391/// feature). Existing callers of `MemoryService::open` see no change — this
392/// is exactly what they already ran.
393#[cfg(feature = "persistence")]
394pub struct NativeStore {
395    memory: AgentMemory,
396    /// Kept beside `memory` (which owns its own clone) for the read paths
397    /// that speak to the engine directly — [`MemoryStore::list`] walks the
398    /// collection cursor, which `AgentMemory` does not re-expose.
399    db: Arc<Database>,
400    capture: MutationCapture,
401}
402
403#[cfg(feature = "persistence")]
404impl NativeStore {
405    /// Open (or create) a native store at `path`, sized for `dimension`.
406    ///
407    /// # Errors
408    /// Returns [`MemoryError`] if the store cannot be opened.
409    pub fn open<P: AsRef<Path>>(path: P, dimension: usize) -> Result<Self, MemoryError> {
410        let db = Arc::new(Database::open(path)?);
411        let memory = AgentMemory::with_dimension(Arc::clone(&db), dimension)?;
412        Ok(Self {
413            memory,
414            db,
415            capture: MutationCapture::default(),
416        })
417    }
418
419    pub(crate) fn set_mutation_observer(
420        &self,
421        observer: Option<Arc<dyn MutationObserver>>,
422    ) -> Result<(), MemoryError> {
423        self.capture.replace(observer)
424    }
425
426    pub(crate) fn mutation_capture_active(&self) -> bool {
427        self.capture.is_active()
428    }
429
430    fn unrelate_unobserved(&self, edge_id: u64) -> Result<bool, MemoryError> {
431        self.memory
432            .semantic()
433            .unrelate(edge_id)
434            .map_err(MemoryError::from)
435    }
436}
437
438#[cfg(feature = "persistence")]
439impl FactStore for NativeStore {
440    fn store(&self, id: u64, content: &str, embedding: &[f32]) -> Result<(), MemoryError> {
441        self.capture.observe(DirtyKey::Fact(id))?;
442        self.memory
443            .semantic()
444            .store(id, content, embedding)
445            .map_err(MemoryError::from)
446    }
447
448    fn store_with_metadata(
449        &self,
450        id: u64,
451        content: &str,
452        embedding: &[f32],
453        metadata: &Metadata,
454    ) -> Result<(), MemoryError> {
455        self.capture.observe(DirtyKey::Fact(id))?;
456        self.memory
457            .semantic()
458            .store_with_metadata(id, content, embedding, metadata)
459            .map_err(MemoryError::from)
460    }
461
462    fn store_with_ttl(
463        &self,
464        id: u64,
465        content: &str,
466        embedding: &[f32],
467        ttl_seconds: u64,
468    ) -> Result<(), MemoryError> {
469        self.capture.observe(DirtyKey::Fact(id))?;
470        self.memory
471            .semantic()
472            .store_with_ttl(id, content, embedding, ttl_seconds)
473            .map_err(MemoryError::from)
474    }
475
476    fn update_metadata(&self, id: u64, metadata: &Metadata) -> Result<(), MemoryError> {
477        self.capture.observe(DirtyKey::Fact(id))?;
478        self.memory
479            .semantic()
480            .update_metadata(id, metadata)
481            .map_err(MemoryError::from)
482    }
483
484    fn store_with_metadata_and_ttl(
485        &self,
486        id: u64,
487        content: &str,
488        embedding: &[f32],
489        metadata: &Metadata,
490        ttl_seconds: u64,
491    ) -> Result<(), MemoryError> {
492        self.capture.observe(DirtyKey::Fact(id))?;
493        // Ordre delibere : le fait est ecrit avec sa metadata et SANS
494        // expiration, donc il ne peut pas expirer entre les deux appels.
495        // L'expiration est posee ensuite. C'est l'inverse de la sequence
496        // historique (store_with_ttl puis update_metadata), ou le fait etait
497        // deja vivant et deja en train d'expirer pendant la seconde ecriture.
498        self.memory
499            .semantic()
500            .store_with_metadata(id, content, embedding, metadata)
501            .map_err(MemoryError::from)?;
502        self.memory
503            .semantic()
504            .set_ttl_durable(id, ttl_seconds)
505            .map_err(MemoryError::from)
506    }
507
508    fn get(&self, id: u64) -> Result<Option<(String, Vec<f32>)>, MemoryError> {
509        self.memory.semantic().get(id).map_err(MemoryError::from)
510    }
511
512    fn get_metadata(&self, id: u64) -> Result<Option<Metadata>, MemoryError> {
513        self.memory
514            .semantic()
515            .get_metadata(id)
516            .map_err(MemoryError::from)
517    }
518
519    fn get_metadata_batch(&self, ids: &[u64]) -> Result<Vec<Option<Metadata>>, MemoryError> {
520        self.memory
521            .semantic()
522            .get_metadata_batch(ids)
523            .map_err(MemoryError::from)
524    }
525
526    fn delete(&self, id: u64) -> Result<(), MemoryError> {
527        self.capture.observe(DirtyKey::Fact(id))?;
528        self.memory.semantic().delete(id).map_err(MemoryError::from)
529    }
530
531    fn count(&self) -> usize {
532        self.memory.semantic().count()
533    }
534
535    fn list(
536        &self,
537        cursor: Option<u64>,
538        limit: usize,
539    ) -> Result<(Vec<RawListedFact>, Option<u64>), MemoryError> {
540        // The migration module's cursor walk, reused verbatim: id-keyed,
541        // ascending, exclusive, and it skips TTL-expired points — exactly
542        // the audit contract (#1762 built it to enumerate a store with full
543        // fidelity, which is what an audit is).
544        let (facts, next) = crate::migration::scroll_page(
545            &self.db,
546            self.memory.semantic().collection_name(),
547            cursor,
548            limit,
549        )?;
550        let listed = facts.iter().map(RawListedFact::from_raw).collect();
551        Ok((listed, next))
552    }
553}
554
555#[cfg(feature = "persistence")]
556impl RecallStore for NativeStore {
557    fn query_filtered(
558        &self,
559        embedding: &[f32],
560        k: usize,
561        filter: &Metadata,
562        offset: usize,
563    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
564        self.memory
565            .semantic()
566            .query_filtered(embedding, k, filter, offset)
567            .map_err(MemoryError::from)
568    }
569
570    fn query_excluding(
571        &self,
572        embedding: &[f32],
573        k: usize,
574        exclude: &Metadata,
575    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
576        self.memory
577            .semantic()
578            .query_excluding(embedding, k, exclude)
579            .map_err(MemoryError::from)
580    }
581}
582
583#[cfg(feature = "persistence")]
584impl ColumnStore for NativeStore {
585    fn query_columnar(
586        &self,
587        embedding: &[f32],
588        k: usize,
589        filters: &[ColumnFilter],
590    ) -> Result<Vec<Recollection>, MemoryError> {
591        let (sql, params) = self.build_fused_query(embedding, k, filters)?;
592        // Field names are validated by `build_fused_query`; ensure each one is
593        // indexed so the planner uses a bitmap prefilter instead of an O(n)
594        // post-filter scan. Idempotent and incrementally maintained thereafter.
595        for field in filters
596            .iter()
597            .map(|filter| filter.field.as_str())
598            .chain(INTERNAL_MARKER_FIELDS.iter().copied())
599        {
600            self.memory
601                .semantic()
602                .ensure_index(field)
603                .map_err(MemoryError::from)?;
604        }
605        let results = self
606            .memory
607            .query_semantic(&sql, &params)
608            .map_err(MemoryError::from)?;
609        Ok(results.iter().map(to_recollection).collect())
610    }
611}
612
613#[cfg(feature = "persistence")]
614impl GraphStore for NativeStore {
615    fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError> {
616        self.capture.observe(DirtyKey::OutgoingEdges(from))?;
617        self.memory
618            .semantic()
619            .relate(from, to, relation, None)
620            .map_err(MemoryError::from)
621    }
622
623    fn relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError> {
624        Ok(to_memory_edges(self.memory.semantic().relations(id)?))
625    }
626
627    fn incoming_relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError> {
628        Ok(to_memory_edges(
629            self.memory.semantic().incoming_relations(id)?,
630        ))
631    }
632
633    fn relations_bounded(&self, id: u64, cap: usize) -> Result<BoundedMemoryEdges, MemoryError> {
634        let bounded = self.memory.semantic().relations_bounded(id, cap)?;
635        Ok(BoundedMemoryEdges {
636            edges: to_memory_edges(bounded.edges),
637            truncated: bounded.truncated,
638        })
639    }
640
641    fn incoming_relations_bounded(
642        &self,
643        id: u64,
644        cap: usize,
645    ) -> Result<BoundedMemoryEdges, MemoryError> {
646        let bounded = self.memory.semantic().incoming_relations_bounded(id, cap)?;
647        Ok(BoundedMemoryEdges {
648            edges: to_memory_edges(bounded.edges),
649            truncated: bounded.truncated,
650        })
651    }
652
653    fn unrelate(&self, edge_id: u64) -> Result<bool, MemoryError> {
654        if self.capture.is_active() {
655            return Err(MemoryError::MigrationCapture(format!(
656                "cannot remove edge {edge_id} without its source id"
657            )));
658        }
659        self.unrelate_unobserved(edge_id)
660    }
661
662    fn unrelate_from(&self, from: u64, edge_id: u64) -> Result<bool, MemoryError> {
663        self.capture.observe(DirtyKey::OutgoingEdges(from))?;
664        self.unrelate_unobserved(edge_id)
665    }
666
667    fn edge_count(&self) -> Option<usize> {
668        // A collection-access failure here means the store is unusable for
669        // every other call too; for a status readout "cannot say" is the
670        // honest degradation, not an error path of its own.
671        self.memory.semantic().edge_count().ok()
672    }
673}
674
675/// Map core [`GraphEdge`](velesdb_core::collection::graph::GraphEdge)s to the
676/// wire-facing [`MemoryEdge`] shape — shared by both edge directions, so the
677/// two can never disagree on which endpoint or id they report.
678#[cfg(feature = "persistence")]
679fn to_memory_edges(edges: Vec<velesdb_core::collection::graph::GraphEdge>) -> Vec<MemoryEdge> {
680    edges
681        .into_iter()
682        .map(|edge| MemoryEdge {
683            id: edge.id(),
684            from: edge.source(),
685            to: edge.target(),
686            relation: edge.label().to_owned(),
687        })
688        .collect()
689}
690
691#[cfg(feature = "persistence")]
692impl NativeStore {
693    /// Build the `VelesQL` for [`Self::query_columnar`]: a `NEAR` predicate
694    /// plus one bound parameter per filter, against the semantic collection.
695    /// Filter *values* are bound as query parameters (never interpolated);
696    /// filter *field names* are validated to be plain identifiers.
697    fn build_fused_query(
698        &self,
699        embedding: &[f32],
700        k: usize,
701        filters: &[ColumnFilter],
702    ) -> Result<(String, HashMap<String, Value>), MemoryError> {
703        use std::fmt::Write as _;
704        let mut params: HashMap<String, Value> = HashMap::new();
705        params.insert("q".to_string(), json!(embedding));
706        let mut predicate = String::from("vector NEAR $q");
707        for (index, filter) in filters.iter().enumerate() {
708            validate_column_filter(filter)?;
709            let key = format!("p{index}");
710            // `ne` is spelled out rather than left to `!=` alone. Core's
711            // `Condition::Neq` is `is_none_or`, so a bare `field != $p` also
712            // matches a fact that HAS no such field — which made `ne` mean two
713            // different things on the two backends (#1759). Requiring the
714            // field first pins the published contract here, at the adapter,
715            // instead of redefining `!=` for all of `VelesQL` — that is a
716            // product-wide semantic break, deliberately out of scope.
717            //
718            // `IS NOT NULL` is false for an absent field AND for an explicit
719            // `null`, which is exactly the contract: a comparison against null
720            // is never true, as in SQL. `IsNull`/`IsNotNull` stay the operators
721            // dedicated to null-ness.
722            if matches!(filter.op, crate::model::ColumnOp::Ne) {
723                let _ = write!(predicate, " AND {} IS NOT NULL", filter.field);
724            }
725            let _ = write!(
726                predicate,
727                " AND {} {} ${key}",
728                filter.field,
729                filter.op.as_sql()
730            );
731            params.insert(key, filter.value.clone());
732        }
733        // Exclude internal scaffolding INSIDE the query rather than after it:
734        // the engine applies `LIMIT k`, so a post-filter would quietly return
735        // fewer than `k` caller facts whenever artefacts crowd the ranking.
736        //
737        // `!=` is what excludes here, and it works for the same reason the
738        // leak existed: `Condition::Neq` is `is_none_or`, so a fact that has
739        // no such column at all MATCHES. Applied to a marker, that keeps every
740        // caller fact (none carries one) and drops exactly the class that
741        // does. These names are compile-time constants, never caller input,
742        // so they go straight into the text without passing through
743        // `validate_column_filter` — whose job is to reject a CALLER filter
744        // naming a reserved key.
745        //
746        // The asymmetry with the caller loop above is DELIBERATE and load-
747        // bearing: a caller's `ne` now carries an `IS NOT NULL`, this exclusion
748        // must NOT. It depends on the absent field matching — that is how it
749        // keeps every caller fact while dropping the marked ones. Giving these
750        // markers the same treatment would exclude every caller fact instead,
751        // since none of them carries a marker column at all.
752        for (index, marker) in INTERNAL_MARKER_FIELDS.iter().enumerate() {
753            let key = format!("m{index}");
754            let _ = write!(predicate, " AND {marker} != ${key}");
755            params.insert(key, json!(true));
756        }
757        let sql = format!(
758            "SELECT * FROM {} WHERE {predicate} LIMIT {k}",
759            self.memory.semantic().collection_name()
760        );
761        Ok((sql, params))
762    }
763}
764
765/// Reserved metadata key `remember`/`remember_with_ttl` auto-stamp with
766/// today's date (a `YYYYMMDD` integer, [`crate::clock::today_ymd`]) whenever
767/// the caller didn't already set it — see
768/// [`crate::service::MemoryService::remember_with_ttl`] for the full
769/// contract. A deliberate, documented **exception** to every other
770/// `_veles_`-namespaced key: [`is_reserved_key`] still names it (so it can
771/// never be confused with an arbitrary caller field), but unlike a true
772/// system key —
773/// - a caller MAY set it explicitly (to date a fact retroactively; never
774///   overwritten once present), and
775/// - it is NOT stripped from caller-facing results, so
776///   [`crate::dated_context::format_dated_context`]'s `date_field` (wired
777///   through `recall_fused`'s `date_field` parameter) can read it back with
778///   zero caller effort.
779///
780/// `pub` (re-exported at the crate root) so every caller of `date_field`
781/// names this one string in exactly one place, not a copy-pasted literal.
782pub const AUTO_DATE_FIELD: &str = "_veles_date";
783
784/// True for metadata keys the memory layer reserves: the engine's `content`
785/// payload, and any `_veles_`-namespaced system key (durable TTL, entity
786/// hubs) — [`AUTO_DATE_FIELD`] EXCEPTED, since (unlike every other reserved
787/// key) it is caller-settable and caller-visible by design. The single
788/// source of the reserved-key contract — the service layer (reject/strip)
789/// and every backend enforce it through this one predicate.
790pub(crate) fn is_reserved_key(key: &str) -> bool {
791    key != AUTO_DATE_FIELD && (key == "content" || key.starts_with("_veles_"))
792}
793
794/// Marks an entity hub minted by `remember_extracted` — graph scaffolding,
795/// never a fact the caller stored.
796pub const HUB_FIELD: &str = "_veles_hub";
797/// Marks a compilation event recorded for `context_savings`.
798pub const CTX_EVENT_FIELD: &str = "_veles_ctx_event";
799/// Marks a stored compilation source, served back by `retrieve_context_source`.
800pub const CTX_SOURCE_FIELD: &str = "_veles_ctx_source";
801/// Marks a saved working context, served back by `load_working_context`.
802pub const CTX_WORKING_FIELD: &str = "_veles_ctx_working";
803/// Marks a project's working-context index, read by `list_working_contexts`.
804pub const CTX_WORKING_INDEX_FIELD: &str = "_veles_ctx_working_index";
805
806/// Every marker that identifies a stored fact as internal scaffolding rather
807/// than a caller memory. Facts of these five classes live in the same
808/// collection as caller facts and are written by exactly one path each; the
809/// markers are declared here, and imported by those paths, so the write and
810/// the exclusion cannot drift apart.
811///
812/// The discriminant is the PRESENCE of one of these keys — deliberately NOT
813/// the `_veles_` prefix. [`AUTO_DATE_FIELD`] (`_veles_date`) is reserved too
814/// and is stamped onto ordinary CALLER facts, so a prefix test would hide the
815/// entire store instead of the scaffolding.
816pub const INTERNAL_MARKER_FIELDS: &[&str] = &[
817    HUB_FIELD,
818    CTX_EVENT_FIELD,
819    CTX_SOURCE_FIELD,
820    CTX_WORKING_FIELD,
821    CTX_WORKING_INDEX_FIELD,
822];
823
824/// Whether a raw payload belongs to one of the five internal classes.
825///
826/// `pub` and shared for the same reason as [`validate_column_filter`]: a
827/// caller-facing recall path must not depend on which backend answered it.
828/// A backend that can test the payload directly should use this; one that
829/// pushes the predicate into a query builds the equivalent there — the
830/// authority on *which* markers count is this list either way.
831#[must_use]
832pub fn is_internal_scaffolding(payload: &Metadata) -> bool {
833    INTERNAL_MARKER_FIELDS
834        .iter()
835        .any(|marker| payload.contains_key(*marker))
836}
837
838/// Drop reserved system keys from a raw payload, and collapse an
839/// empty-after-stripping map to `None` — the caller-facing shape every
840/// [`Recollection::metadata`] is built from. `pub` because a [`MemoryStore`]
841/// backend that assembles `Recollection`s itself (`query_columnar`) must
842/// apply the same stripping the service layer applies on every other recall
843/// path, or reserved keys leak to callers on that one path only.
844#[must_use]
845pub fn strip_reserved_keys(payload: Option<Metadata>) -> Option<Metadata> {
846    payload.and_then(|payload| {
847        let metadata: Metadata = payload
848            .into_iter()
849            .filter(|(key, _)| !is_reserved_key(key))
850            .collect();
851        (!metadata.is_empty()).then_some(metadata)
852    })
853}
854
855/// [`strip_reserved_keys`] over a *borrowed* payload: clones only the
856/// surviving non-reserved entries. Use this when the payload isn't already
857/// owned — cloning the whole map first would deep-copy the reserved
858/// `content` value (the full fact text) per hit, only to discard it.
859#[must_use]
860pub fn strip_reserved_keys_ref(payload: Option<&Metadata>) -> Option<Metadata> {
861    payload.and_then(|payload| {
862        let metadata: Metadata = payload
863            .iter()
864            .filter(|(key, _)| !is_reserved_key(key))
865            .map(|(key, value)| (key.clone(), value.clone()))
866            .collect();
867        (!metadata.is_empty()).then_some(metadata)
868    })
869}
870
871/// Map a core search result to a [`Recollection`], lifting the fact text out
872/// of the reserved `content` payload key and surfacing any remaining
873/// caller-supplied metadata (reserved system keys excluded).
874#[cfg(feature = "persistence")]
875fn to_recollection(result: &SearchResult) -> Recollection {
876    let payload = result.point.payload.as_ref().and_then(Value::as_object);
877    let content = payload
878        .and_then(|payload| payload.get("content"))
879        .and_then(Value::as_str)
880        .unwrap_or_default()
881        .to_owned();
882    Recollection {
883        id: result.point.id,
884        score: result.score,
885        content,
886        metadata: strip_reserved_keys_ref(payload),
887    }
888}
889
890/// Validate one `recall_where` column filter: a plain, non-reserved
891/// identifier field name and a scalar (string/number/boolean) value. `pub`
892/// and shared so every [`MemoryStore`] backend enforces the *same* documented
893/// contract — the field-name rule keeps a filter safe to place into query
894/// text (`NativeStore` builds `VelesQL`; values are always bound parameters),
895/// and rejects the reserved system columns (`content`, `_veles_*`) regardless
896/// of backend; the scalar rule turns what would be an opaque engine error
897/// into a clear client-input error.
898///
899/// # Errors
900/// Returns [`MemoryError::InvalidFilter`] when either rule is violated.
901pub fn validate_column_filter(filter: &ColumnFilter) -> Result<(), MemoryError> {
902    let field = &filter.field;
903    let plain = !field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
904    if !plain || is_reserved_key(field) {
905        return Err(MemoryError::InvalidFilter(field.clone()));
906    }
907    match &filter.value {
908        Value::String(_) | Value::Number(_) | Value::Bool(_) => Ok(()),
909        value => Err(MemoryError::InvalidFilter(format!(
910            "value must be a string, number, or boolean, got {value}"
911        ))),
912    }
913}
914
915#[cfg(all(test, feature = "persistence"))]
916#[path = "storage_tests.rs"]
917mod tests;