Skip to main content

mati_core/store/db/
crud.rs

1//! Core CRUD and transactional batch writes.
2
3use super::*;
4
5/// Returns `true` for keys whose writes should invalidate cached stats snapshots.
6///
7/// These are the namespaces that affect the knowledge coverage aggregates
8/// displayed by `mati stats` and `mati gaps`. Must stay in sync with
9/// [`KNOWLEDGE_NAMESPACES`].
10fn is_knowledge_key(key: &str) -> bool {
11    key.starts_with("file:")
12        || key.starts_with("gotcha:")
13        || key.starts_with("decision:")
14        || key.starts_with("dep:")
15        || key.starts_with("dev_note:")
16        || key.starts_with("stage:")
17}
18
19/// Read and deserialize a record from an active transaction.
20fn read_record(txn: &Transaction, key: &str) -> Result<Option<Record>> {
21    match txn.get(key.as_bytes())? {
22        None => Ok(None),
23        Some(bytes) => {
24            let record = rmps::from_slice::<Record>(&bytes)
25                .with_context(|| format!("corrupt record at key '{key}'"))?;
26            Ok(Some(record))
27        }
28    }
29}
30
31/// Map mati's `Durability` enum to SurrealKV's `Durability`.
32fn skv_durability(d: Durability) -> SkvDurability {
33    match d {
34        Durability::Immediate => SkvDurability::Immediate,
35        Durability::Eventual => SkvDurability::Eventual,
36    }
37}
38
39/// Return the smallest string that is lexicographically greater than all keys
40/// starting with `prefix`. Used to form the exclusive upper bound for range
41/// scans.
42pub(super) fn prefix_end(prefix: &str) -> String {
43    let mut bytes = prefix.as_bytes().to_vec();
44    // Increment the last byte; if it wraps (0xff → 0x00) keep carrying.
45    for b in bytes.iter_mut().rev() {
46        if *b < 0xff {
47            *b += 1;
48            return String::from_utf8(bytes).unwrap_or_else(|_| "\u{ffff}".to_owned());
49        }
50        *b = 0x00;
51    }
52    // All bytes were 0xff — no upper bound needed; use a sentinel
53    "\u{ffff}".to_owned()
54}
55
56impl Store {
57    // -------------------------------------------------------------------------
58    // Core CRUD
59    // -------------------------------------------------------------------------
60
61    /// Read a record by key. Returns `None` if not found.
62    pub async fn get(&self, key: &str) -> Result<Option<Record>> {
63        let txn = self.tree_for(key).begin_with_mode(Mode::ReadOnly)?;
64        read_record(&txn, key)
65    }
66
67    /// Write a record with the appropriate durability level.
68    ///
69    /// Durability is derived from the key prefix via [`Durability::for_key`].
70    pub async fn put(&self, key: &str, record: &Record) -> Result<()> {
71        debug_assert_eq!(
72            Encoding::for_key(key),
73            Encoding::Record,
74            "put() writes a Record into {key}, which is a Raw namespace; see store::durability::Encoding"
75        );
76        let durability = Durability::for_key(key);
77        let tree = self.tree_for(key);
78        let mut txn = tree.begin_with_mode(Mode::WriteOnly)?;
79        txn.set_durability(skv_durability(durability));
80
81        let bytes = rmps::to_vec_named(record)
82            .with_context(|| format!("failed to serialize record for key '{key}'"))?;
83        txn.set(key.as_bytes(), bytes)?;
84        txn.commit().await?;
85
86        // Crash-fence: written after KV commit, removed after tantivy commit.
87        // If the process dies between these two points, open_and_rebuild sees
88        // the marker on the next start and triggers a full index rebuild.
89        if is_knowledge_key(key) {
90            let _ = std::fs::write(self.root.join(SEARCH_SYNC_PENDING), b"");
91        }
92
93        // Update search index — KV write is primary, search is secondary.
94        // We replace by key rather than append, so tantivy stays aligned with
95        // the latest KV state without waiting for a full rebuild.
96        //
97        // Wrapped in catch_unwind: a tantivy panic (e.g., corrupted segment)
98        // must never crash the server. The KV write already committed above —
99        // the search index will be rebuilt on next startup via the
100        // SEARCH_SYNC_PENDING crash-fence marker.
101        let mut search_synced = false;
102        match self.ensure_search() {
103            Ok(search) => {
104                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
105                    search.add_record(record)
106                })) {
107                    Ok(Ok(())) => {
108                        search_synced = true;
109                    }
110                    Ok(Err(e)) => {
111                        tracing::warn!("search index update failed for '{key}': {e}");
112                    }
113                    Err(_panic) => {
114                        tracing::error!(
115                            "search index panicked during put for '{key}' — \
116                             index will be rebuilt on next startup"
117                        );
118                    }
119                }
120            }
121            Err(e) => {
122                tracing::warn!("search index unavailable during put: {e}");
123            }
124        }
125        if is_knowledge_key(key) {
126            self.bump_write_seq();
127            if search_synced {
128                let _ = std::fs::remove_file(self.root.join(SEARCH_SYNC_PENDING));
129            }
130        }
131        Ok(())
132    }
133
134    /// Write multiple records to KV only, skipping the tantivy search index.
135    ///
136    /// Use this during bulk init passes where search indexing would block the
137    /// critical path. Follow with [`Self::rebuild_search_index`] to update tantivy
138    /// from the same in-memory records without a KV round-trip.
139    ///
140    /// Same durability semantics as [`Self::put_batch`]: at most 2 fsyncs.
141    pub async fn put_batch_kv_only(&self, records: &[(&str, &Record)]) -> Result<()> {
142        if records.is_empty() {
143            return Ok(());
144        }
145        let mut immediate: Vec<(&str, &Record)> = Vec::new();
146        let mut eventual: Vec<(&str, &Record)> = Vec::new();
147        for &(key, record) in records {
148            match Durability::for_key(key) {
149                Durability::Immediate => immediate.push((key, record)),
150                Durability::Eventual => eventual.push((key, record)),
151            }
152        }
153        if !immediate.is_empty() {
154            let mut txn = self.knowledge.begin_with_mode(Mode::WriteOnly)?;
155            txn.set_durability(SkvDurability::Immediate);
156            for (key, record) in &immediate {
157                let bytes = rmps::to_vec_named(record)
158                    .with_context(|| format!("failed to serialize record for key '{key}'"))?;
159                txn.set(key.as_bytes(), bytes)?;
160            }
161            txn.commit().await?;
162        }
163        if !eventual.is_empty() {
164            let mut txn = self.sessions.begin_with_mode(Mode::WriteOnly)?;
165            txn.set_durability(SkvDurability::Eventual);
166            for (key, record) in &eventual {
167                let bytes = rmps::to_vec_named(record)
168                    .with_context(|| format!("failed to serialize record for key '{key}'"))?;
169                txn.set(key.as_bytes(), bytes)?;
170            }
171            txn.commit().await?;
172        }
173        if records.iter().any(|(k, _)| is_knowledge_key(k)) {
174            self.bump_write_seq();
175        }
176        Ok(())
177    }
178
179    /// Mark the search index as stale so the next [`Self::open_and_rebuild`]
180    /// call wipes and rebuilds it from KV.
181    ///
182    /// Written by `mati init` after a cold init pass to defer the tantivy
183    /// indexing cost (~400ms on 27k records) to the first MCP server startup.
184    /// Best-effort: a write failure is silently discarded — the worst outcome
185    /// is that the search index contains stale data until the next full rebuild.
186    pub fn mark_search_stale(&self) {
187        let _ = std::fs::write(self.root.join(SEARCH_STALE_MARKER), b"");
188    }
189
190    /// Write multiple records in a single transaction per durability class.
191    ///
192    /// Records are grouped by their key prefix: all `Immediate` keys share one
193    /// transaction on `knowledge` (1 fsync), all `Eventual` keys share one on
194    /// `sessions` (1 fsync). The whole batch costs at most 2 fsyncs regardless
195    /// of how many records it contains — critical for Layer 0 bulk inserts.
196    ///
197    /// Empty slice is a no-op. Mixed-durability batches are handled correctly.
198    pub async fn put_batch(&self, records: &[(&str, &Record)]) -> Result<()> {
199        if records.is_empty() {
200            return Ok(());
201        }
202
203        // Partition by durability class so each tree gets exactly one commit.
204        let mut immediate: Vec<(&str, &Record)> = Vec::new();
205        let mut eventual: Vec<(&str, &Record)> = Vec::new();
206        for &(key, record) in records {
207            match Durability::for_key(key) {
208                Durability::Immediate => immediate.push((key, record)),
209                Durability::Eventual => eventual.push((key, record)),
210            }
211        }
212
213        if !immediate.is_empty() {
214            let mut txn = self.knowledge.begin_with_mode(Mode::WriteOnly)?;
215            txn.set_durability(SkvDurability::Immediate);
216            for (key, record) in &immediate {
217                let bytes = rmps::to_vec_named(record)
218                    .with_context(|| format!("failed to serialize record for key '{key}'"))?;
219                txn.set(key.as_bytes(), bytes)?;
220            }
221            txn.commit().await?;
222        }
223
224        if !eventual.is_empty() {
225            let mut txn = self.sessions.begin_with_mode(Mode::WriteOnly)?;
226            txn.set_durability(SkvDurability::Eventual);
227            for (key, record) in &eventual {
228                let bytes = rmps::to_vec_named(record)
229                    .with_context(|| format!("failed to serialize record for key '{key}'"))?;
230                txn.set(key.as_bytes(), bytes)?;
231            }
232            txn.commit().await?;
233        }
234
235        let has_knowledge = records.iter().any(|(k, _)| is_knowledge_key(k));
236
237        // Crash-fence — same pattern as put().
238        if has_knowledge {
239            let _ = std::fs::write(self.root.join(SEARCH_SYNC_PENDING), b"");
240        }
241
242        // Update search index — KV write is primary, search is secondary.
243        // If tantivy fails to initialize, the KV writes still succeeded.
244        // Wrapped in catch_unwind for the same reason as put().
245        let mut search_synced = false;
246        match self.ensure_search() {
247            Ok(search) => {
248                let search_records: Vec<&Record> = records.iter().map(|(_, r)| *r).collect();
249                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
250                    search.add_records(&search_records)
251                })) {
252                    Ok(Ok(_)) => {
253                        search_synced = true;
254                    }
255                    Ok(Err(e)) => {
256                        tracing::warn!("search index update failed in put_batch: {e}");
257                    }
258                    Err(_panic) => {
259                        tracing::error!(
260                            "search index panicked during put_batch — \
261                             index will be rebuilt on next startup"
262                        );
263                    }
264                }
265            }
266            Err(e) => {
267                tracing::warn!("search index unavailable during put_batch: {e}");
268            }
269        }
270        if has_knowledge {
271            self.bump_write_seq();
272            if search_synced {
273                let _ = std::fs::remove_file(self.root.join(SEARCH_SYNC_PENDING));
274            }
275        }
276        Ok(())
277    }
278
279    /// Delete a record by key. No-op if the key does not exist.
280    pub async fn delete(&self, key: &str) -> Result<()> {
281        let tree = self.tree_for(key);
282        let mut txn = tree.begin_with_mode(Mode::WriteOnly)?;
283        txn.set_durability(skv_durability(Durability::for_key(key)));
284        txn.delete(key.as_bytes())?;
285        txn.commit().await?;
286
287        if is_knowledge_key(key) {
288            let _ = std::fs::write(self.root.join(SEARCH_SYNC_PENDING), b"");
289        }
290
291        let mut search_synced = false;
292        match self.ensure_search() {
293            Ok(search) => {
294                search.delete_key(key)?;
295                search_synced = true;
296            }
297            Err(e) => {
298                tracing::warn!("search index unavailable during delete: {e}");
299            }
300        }
301
302        if is_knowledge_key(key) {
303            self.bump_write_seq();
304            if search_synced {
305                let _ = std::fs::remove_file(self.root.join(SEARCH_SYNC_PENDING));
306            }
307        }
308        Ok(())
309    }
310
311    /// Return all records whose key starts with `prefix`.
312    ///
313    /// Prefix must use one of the known key namespaces so the correct tree is
314    /// selected. Unknown prefixes are scanned from `knowledge`.
315    ///
316    /// Return order is not guaranteed. Callers that need a stable order must sort.
317    pub async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>> {
318        debug_assert_eq!(
319            Encoding::for_key(prefix),
320            Encoding::Record,
321            "scan_prefix() deserializes Records from {prefix}, which is a Raw namespace; use scan_keys instead"
322        );
323        let tree = self.tree_for(prefix);
324        let txn = tree.begin_with_mode(Mode::ReadOnly)?;
325
326        // Range: [prefix, prefix\xff) covers all keys with this prefix
327        let end = prefix_end(prefix);
328        let iter = txn.range(prefix.as_bytes(), end.as_bytes())?;
329
330        let mut records = Vec::new();
331        let mut cursor = iter;
332        while cursor.next()? {
333            let bytes = cursor.value()?;
334            match rmps::from_slice::<Record>(&bytes) {
335                Ok(record) => records.push(record),
336                Err(e) => {
337                    tracing::warn!("skipping malformed record during scan: {e}");
338                }
339            }
340        }
341        Ok(records)
342    }
343
344    /// Scan records whose key starts with `prefix`, invoking `callback` for each.
345    ///
346    /// Same tree routing and prefix semantics as [`Self::scan_prefix`], but records
347    /// are deserialized and passed to `callback` one at a time rather than
348    /// collected into a `Vec`. Callers can begin processing (e.g. printing to
349    /// stdout) before the full scan completes, giving time-to-first-row
350    /// latency proportional to a single deserialization rather than the full
351    /// scan.
352    ///
353    /// Return order is lexicographic (underlying KV order). Callers that need
354    /// a different order must collect and sort after the fact.
355    pub async fn scan_prefix_each<F>(&self, prefix: &str, mut callback: F) -> Result<()>
356    where
357        F: FnMut(Record),
358    {
359        debug_assert_eq!(
360            Encoding::for_key(prefix),
361            Encoding::Record,
362            "scan_prefix_each() deserializes Records from {prefix}, which is a Raw namespace; use scan_keys instead"
363        );
364        let tree = self.tree_for(prefix);
365        let txn = tree.begin_with_mode(Mode::ReadOnly)?;
366        let end = prefix_end(prefix);
367        let mut cursor = txn.range(prefix.as_bytes(), end.as_bytes())?;
368        while cursor.next()? {
369            let bytes = cursor.value()?;
370            match rmps::from_slice::<Record>(&bytes) {
371                Ok(record) => callback(record),
372                Err(e) => {
373                    tracing::warn!("skipping malformed record during scan: {e}");
374                }
375            }
376        }
377        Ok(())
378    }
379
380    /// Full-text BM25 search over all indexed records.
381    ///
382    /// Calls tantivy for the top `limit` matching keys, then fetches each full
383    /// record from SurrealKV. Keys that tantivy returns but are not found in
384    /// the store (e.g. deleted since last commit) are silently skipped.
385    ///
386    /// Returns results ordered by descending BM25 relevance score. Returns an
387    /// empty `Vec` when `text` is blank or `limit` is 0.
388    pub async fn search(&self, text: &str, limit: usize) -> Result<Vec<Record>> {
389        let search = self.ensure_search()?;
390        let keys = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
391            search.query_keys(text, limit)
392        })) {
393            Ok(result) => result?,
394            Err(_panic) => {
395                tracing::error!("search index panicked during query — returning empty results");
396                return Ok(vec![]);
397            }
398        };
399        let mut records = Vec::with_capacity(keys.len());
400        for key in &keys {
401            if let Some(record) = self.get(key).await? {
402                records.push(record);
403            }
404        }
405        Ok(records)
406    }
407
408    /// Full-text BM25 search returning `(score, Record)` pairs.
409    ///
410    /// Same semantics as [`Self::search`] but preserves the raw BM25
411    /// relevance score from tantivy. Used by `mem_query` text mode to
412    /// include relevance in the agent-facing response.
413    pub async fn search_scored(&self, text: &str, limit: usize) -> Result<Vec<(f32, Record)>> {
414        let search = self.ensure_search()?;
415        let scored_keys = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
416            search.query_keys_scored(text, limit)
417        })) {
418            Ok(result) => result?,
419            Err(_panic) => {
420                tracing::error!(
421                    "search index panicked during scored query — returning empty results"
422                );
423                return Ok(vec![]);
424            }
425        };
426        let mut results = Vec::with_capacity(scored_keys.len());
427        for (score, key) in &scored_keys {
428            if let Some(record) = self.get(key).await? {
429                results.push((*score, record));
430            }
431        }
432        Ok(results)
433    }
434
435    /// Read raw bytes by key. Returns `None` if the key does not exist.
436    ///
437    /// Counterpart to [`Self::put_raw`]. Used for structural metadata,
438    /// enforcement events, and other non-Record values stored as raw bytes.
439    pub async fn get_raw_bytes(&self, key: &str) -> Result<Option<Vec<u8>>> {
440        let tree = self.tree_for(key);
441        let txn = tree.begin_with_mode(Mode::ReadOnly)?;
442        match txn.get(key.as_bytes())? {
443            None => Ok(None),
444            Some(bytes) => Ok(Some(bytes.to_vec())),
445        }
446    }
447
448    /// Write raw bytes under `key` with automatically routed durability.
449    ///
450    /// Same durability routing as [`Self::put`] — callers do not need to know
451    /// which tree a key belongs to. Use this for structural metadata (graph
452    /// edges, etc.) where the value is not a [`Record`] and does not need to
453    /// be deserialised on reads.
454    pub async fn put_raw(&self, key: &str, value: &[u8]) -> Result<()> {
455        debug_assert_eq!(
456            Encoding::for_key(key),
457            Encoding::Raw,
458            "put_raw() writes bare bytes into {key}, which is a Record namespace; a prefix scan would skip it. See store::durability::Encoding"
459        );
460        let durability = Durability::for_key(key);
461        let tree = self.tree_for(key);
462        let mut txn = tree.begin_with_mode(Mode::WriteOnly)?;
463        txn.set_durability(skv_durability(durability));
464        txn.set(key.as_bytes(), value.to_vec())?;
465        txn.commit().await?;
466        Ok(())
467    }
468
469    /// Write multiple raw-byte values in a single transaction per durability class.
470    ///
471    /// Same batch semantics as [`Self::put_batch`] (at most 2 fsyncs for the
472    /// whole batch). Use for bulk structural writes like graph edge inserts.
473    pub async fn put_batch_raw(&self, records: &[(&str, &[u8])]) -> Result<()> {
474        if records.is_empty() {
475            return Ok(());
476        }
477
478        let mut immediate: Vec<(&str, &[u8])> = Vec::new();
479        let mut eventual: Vec<(&str, &[u8])> = Vec::new();
480        for &(key, value) in records {
481            match Durability::for_key(key) {
482                Durability::Immediate => immediate.push((key, value)),
483                Durability::Eventual => eventual.push((key, value)),
484            }
485        }
486
487        if !immediate.is_empty() {
488            let mut txn = self.knowledge.begin_with_mode(Mode::WriteOnly)?;
489            txn.set_durability(SkvDurability::Immediate);
490            for (key, value) in &immediate {
491                txn.set(key.as_bytes(), value.to_vec())?;
492            }
493            txn.commit().await?;
494        }
495
496        if !eventual.is_empty() {
497            let mut txn = self.sessions.begin_with_mode(Mode::WriteOnly)?;
498            txn.set_durability(SkvDurability::Eventual);
499            for (key, value) in &eventual {
500                txn.set(key.as_bytes(), value.to_vec())?;
501            }
502            txn.commit().await?;
503        }
504
505        Ok(())
506    }
507
508    // -------------------------------------------------------------------------
509    // Transactional batch writes (mutation + audit atomic commit)
510    //
511    // SurrealKV supports multi-key atomic transactions within a single tree.
512    // The real constraint is mati's two-tree architecture: no single
513    // transaction can span both the knowledge and sessions trees.
514    // -------------------------------------------------------------------------
515
516    /// Atomically commit multiple writes to the knowledge tree in a single
517    /// transaction.
518    ///
519    /// Supports mixed Record + raw byte writes. All keys MUST route to the
520    /// knowledge tree (`Durability::Immediate`). Returns an error if any key
521    /// routes to sessions.
522    ///
523    /// Handles crash-fence + tantivy sync + write-seq after commit.
524    /// Use this for mutation + audit atomic commit on knowledge-side commands.
525    pub async fn transact_knowledge(&self, ops: &[KnowledgeWriteOp<'_>]) -> Result<()> {
526        if ops.is_empty() {
527            return Ok(());
528        }
529        for op in ops {
530            let k = match op {
531                KnowledgeWriteOp::PutRecord { key, .. } => *key,
532                KnowledgeWriteOp::PutRaw { key, .. } => *key,
533            };
534            if Durability::for_key(k) != Durability::Immediate {
535                anyhow::bail!(
536                    "transact_knowledge: key '{k}' routes to sessions tree, not knowledge"
537                );
538            }
539        }
540
541        // Collect Records for tantivy sync before committing (we need &Record refs).
542        let mut record_refs: Vec<&Record> = Vec::new();
543
544        let mut txn = self.knowledge.begin_with_mode(Mode::WriteOnly)?;
545        txn.set_durability(SkvDurability::Immediate);
546        for op in ops {
547            match op {
548                KnowledgeWriteOp::PutRecord { key, record } => {
549                    let bytes = rmps::to_vec_named(record)
550                        .with_context(|| format!("failed to serialize record for key '{key}'"))?;
551                    txn.set(key.as_bytes(), bytes)?;
552                    record_refs.push(record);
553                }
554                KnowledgeWriteOp::PutRaw { key, value } => {
555                    txn.set(key.as_bytes(), value.to_vec())?;
556                }
557            }
558        }
559        txn.commit().await?;
560
561        // Crash-fence + tantivy sync (same pattern as put/put_batch).
562        let has_knowledge = ops.iter().any(|op| {
563            let k = match op {
564                KnowledgeWriteOp::PutRecord { key, .. } => key,
565                KnowledgeWriteOp::PutRaw { key, .. } => key,
566            };
567            is_knowledge_key(k)
568        });
569        if has_knowledge {
570            let _ = std::fs::write(self.root.join(SEARCH_SYNC_PENDING), b"");
571        }
572        let mut search_synced = false;
573        if !record_refs.is_empty() {
574            if let Ok(search) = self.ensure_search() {
575                match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
576                    search.add_records(&record_refs)
577                })) {
578                    Ok(Ok(_)) => search_synced = true,
579                    Ok(Err(e)) => tracing::warn!("transact_knowledge: tantivy sync failed: {e}"),
580                    Err(_) => tracing::error!("transact_knowledge: tantivy panicked"),
581                }
582            }
583        }
584        if has_knowledge {
585            self.bump_write_seq();
586            if search_synced {
587                let _ = std::fs::remove_file(self.root.join(SEARCH_SYNC_PENDING));
588            }
589        }
590        Ok(())
591    }
592
593    /// Atomically commit multiple raw byte writes to the sessions tree in a
594    /// single transaction.
595    ///
596    /// All keys MUST route to the sessions tree (`Durability::Eventual`).
597    /// Returns an error if any key routes to knowledge.
598    ///
599    /// Use this for mutation + audit atomic commit on session-side commands.
600    pub async fn transact_sessions_raw(&self, entries: &[(&str, &[u8])]) -> Result<()> {
601        if entries.is_empty() {
602            return Ok(());
603        }
604        for (k, _) in entries {
605            if Durability::for_key(k) != Durability::Eventual {
606                anyhow::bail!(
607                    "transact_sessions_raw: key '{k}' routes to knowledge tree, not sessions"
608                );
609            }
610        }
611
612        let mut txn = self.sessions.begin_with_mode(Mode::WriteOnly)?;
613        txn.set_durability(SkvDurability::Eventual);
614        for (key, value) in entries {
615            txn.set(key.as_bytes(), value.to_vec())?;
616        }
617        txn.commit().await?;
618        Ok(())
619    }
620
621    /// Return all keys whose prefix matches, without deserialising values.
622    ///
623    /// Cheaper than [`Self::scan_prefix`] when only the key is needed (e.g.
624    /// graph edge loading, existence checks). Uses the SurrealKV iterator
625    /// `key().user_key()` path so value bytes are never read from disk.
626    pub async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>> {
627        let tree = self.tree_for(prefix);
628        let txn = tree.begin_with_mode(Mode::ReadOnly)?;
629        let end = prefix_end(prefix);
630        let mut cursor = txn.range(prefix.as_bytes(), end.as_bytes())?;
631
632        let mut keys = Vec::new();
633        while cursor.next()? {
634            let user_key = cursor.key().user_key();
635            match std::str::from_utf8(user_key) {
636                Ok(s) => keys.push(s.to_string()),
637                Err(e) => tracing::warn!("skipping non-UTF8 key in scan_keys: {e}"),
638            }
639        }
640        Ok(keys)
641    }
642}