Skip to main content

hyperdb_api/
kv_store.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Key-value store over a fixed Hyper table.
5//!
6//! [`KvStore`] is an ergonomic string-native KV abstraction backed by a
7//! single table, `KV_TABLE`, namespaced by a `store_name` column. Every
8//! named store shares that table; a handle binds one store name, validated
9//! once at [`Connection::kv_store`](crate::Connection::kv_store).
10//!
11//! Hyper has no native KV store and no `ON CONFLICT`/`MERGE`; `set` is an
12//! `UPDATE`-then-conditional-`INSERT` upsert. See the crate `DEVELOPMENT.md`
13//! for the design rationale.
14
15use crate::error::{Error, Result};
16
17/// Fixed backing table for every named KV store.
18///
19/// The `_hyperdb_` prefix matches the crate's internal-table convention so
20/// downstream tooling can auto-hide it from schema listings.
21pub(crate) const KV_TABLE: &str = "_hyperdb_kv_store";
22
23/// Maximum length, in bytes, of a store name or key.
24pub(crate) const KV_MAX_NAME_BYTES: usize = 512;
25
26/// Human-readable description of the allowed store-name/key charset.
27///
28/// Used in validation error messages so the allowed set is stated in one
29/// place (M-DOCUMENTED-MAGIC) rather than duplicated as a string literal.
30pub(crate) const KV_CHARSET: &str = "A-Z a-z 0-9 _ . -";
31
32/// Validates a store name or key: non-empty, ASCII `[A-Za-z0-9_.-]+`, `<= 512` bytes.
33///
34/// `kind` labels the value in the error message (`"store name"` / `"key"`).
35///
36/// # Errors
37///
38/// Returns [`Error::InvalidName`] if `name` is empty, exceeds
39/// [`KV_MAX_NAME_BYTES`] bytes, or contains a byte outside the ASCII
40/// [`KV_CHARSET`] (`A-Z a-z 0-9 _ . -`).
41pub(crate) fn validate_kv_name(name: &str, kind: &str) -> Result<()> {
42    if name.is_empty() {
43        return Err(Error::invalid_name(format!("KV {kind} must not be empty")));
44    }
45    if name.len() > KV_MAX_NAME_BYTES {
46        return Err(Error::invalid_name(format!(
47            "KV {kind} exceeds {KV_MAX_NAME_BYTES}-byte limit ({} bytes)",
48            name.len()
49        )));
50    }
51    if let Some(bad) = name
52        .bytes()
53        .find(|&b| !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-'))
54    {
55        return Err(Error::invalid_name(format!(
56            "KV {kind} contains an invalid byte {bad:#04x}; allowed: {KV_CHARSET}"
57        )));
58    }
59    Ok(())
60}
61
62/// Builds the `CREATE TABLE IF NOT EXISTS` DDL for the KV backing table.
63///
64/// Single source of truth for the schema, shared by the sync and async
65/// constructors and both `kv_list_stores` guards. The table has **no**
66/// `PRIMARY KEY`: Hyper rejects one at create time (`0A000: Index support is
67/// disabled`, see `hyperdb-mcp/src/table_catalog.rs`), so per-`(store_name,
68/// key)` uniqueness is an application-side invariant enforced by the
69/// UPDATE-then-conditional-INSERT upsert, not an engine constraint.
70pub(crate) fn kv_create_table_sql(table_ref: &str) -> String {
71    format!(
72        "CREATE TABLE IF NOT EXISTS {table_ref} \
73         (store_name TEXT NOT NULL, key TEXT NOT NULL, value TEXT)"
74    )
75}
76
77/// Builds the escaped `"<database>"."public"` qualifier prefix for the KV table.
78///
79/// Single source of truth for the location shape used by
80/// [`Connection::kv_store_in`](crate::Connection::kv_store_in) and
81/// [`Connection::kv_list_stores_in`](crate::Connection::kv_list_stores_in) (and
82/// their async twins): the KV table always lives in the target database's
83/// `public` schema. `database` is identifier-escaped via
84/// [`escape_name`](crate::escape_name); the fixed `public` schema name is
85/// escaped identically for symmetry.
86pub(crate) fn kv_target_prefix(database: &str) -> Result<String> {
87    Ok(format!(
88        "{}.{}",
89        crate::escape_name(database)?,
90        crate::escape_name("public")?
91    ))
92}
93
94use crate::connection::Connection;
95
96/// Outcome of a single KV write.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct SetOutcome {
99    /// `true` if the key did not previously exist (insert); `false` if an
100    /// existing value was overwritten.
101    pub created: bool,
102}
103
104/// Outcome of a batch KV write.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct BatchSetOutcome {
107    /// Number of keys newly inserted.
108    pub created: usize,
109    /// Number of keys whose prior value was replaced.
110    pub overwritten: usize,
111}
112
113/// Outcome of a guarded batch write (`set_batch_if_absent`).
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct BatchGuardOutcome {
116    /// Number of keys newly inserted.
117    pub written: usize,
118    /// Number of keys skipped because they already existed.
119    pub skipped: usize,
120}
121
122/// A handle to one named key-value store, backed by `KV_TABLE`.
123///
124/// Borrows its [`Connection`] for the handle's lifetime (`'conn`), matching
125/// the crate's [`Catalog`](crate::Catalog)/[`Inserter`](crate::Inserter)
126/// borrow convention. Open one with
127/// [`Connection::kv_store`](crate::Connection::kv_store).
128///
129/// # Examples
130///
131/// ```no_run
132/// use hyperdb_api::{Connection, CreateMode, Result};
133///
134/// fn main() -> Result<()> {
135///     let conn = Connection::connect("localhost:7483", "app.hyper", CreateMode::CreateIfNotExists)?;
136///     let kv = conn.kv_store("settings")?;
137///     kv.set("theme", "dark")?;
138///     assert_eq!(kv.get("theme")?, Some("dark".to_string()));
139///     Ok(())
140/// }
141/// ```
142#[derive(Debug)]
143pub struct KvStore<'conn> {
144    connection: &'conn Connection,
145    store_name: String,
146    table_ref: String,
147}
148
149impl<'conn> KvStore<'conn> {
150    /// Opens a handle to `name`, creating `KV_TABLE` if needed.
151    fn open(connection: &'conn Connection, name: &str, table_ref: String) -> Result<Self> {
152        validate_kv_name(name, "store name")?;
153        connection.execute_command(&kv_create_table_sql(&table_ref))?;
154        Ok(KvStore {
155            connection,
156            store_name: name.to_string(),
157            table_ref,
158        })
159    }
160
161    /// Opens a handle to a store in the default location.
162    pub(crate) fn new(connection: &'conn Connection, name: &str) -> Result<Self> {
163        Self::open(connection, name, KV_TABLE.to_string())
164    }
165
166    /// Opens a handle targeting an explicit, already-escaped table-qualifier prefix.
167    ///
168    /// Crate-internal low-level constructor behind
169    /// [`Connection::kv_store_in`](crate::Connection::kv_store_in). `target` is
170    /// interpolated directly into SQL, so the **caller must supply a pre-escaped,
171    /// SQL-safe qualifier** — public callers go through `kv_store_in`, which
172    /// escapes for them (`store_name` / `key` / `value` are always bound params,
173    /// but `target` is not).
174    pub(crate) fn with_target(
175        connection: &'conn Connection,
176        name: &str,
177        target: &str,
178    ) -> Result<Self> {
179        Self::open(connection, name, format!("{target}.{KV_TABLE}"))
180    }
181
182    /// Returns this store's validated name.
183    #[must_use]
184    pub fn name(&self) -> &str {
185        &self.store_name
186    }
187
188    /// Returns the value for `key`, or `None` if the key is absent or NULL.
189    ///
190    /// # Errors
191    ///
192    /// - [`Error::InvalidName`] if `key` is invalid.
193    /// - [`Error::FeatureNotSupported`] on gRPC transport.
194    /// - [`Error::Server`] if the query fails.
195    pub fn get(&self, key: &str) -> Result<Option<String>> {
196        validate_kv_name(key, "key")?;
197        let sql = format!(
198            "SELECT value FROM {} WHERE store_name = $1 AND key = $2",
199            self.table_ref
200        );
201        // Bind store_name/key as `&str` params (never interpolated) — uniform
202        // `&str` element types coerce cleanly to `&[&dyn ToSqlParam]`.
203        let row = self
204            .connection
205            .query_params(&sql, &[&self.store_name.as_str(), &key])?
206            .first_row()?;
207        Ok(row.and_then(|r| r.get::<String>(0)))
208    }
209
210    /// Sets `key` to `value`, inserting or overwriting (upsert). Returns
211    /// [`SetOutcome`] indicating whether the key was newly created.
212    ///
213    /// # Errors
214    ///
215    /// - [`Error::InvalidName`] if `key` is invalid.
216    /// - [`Error::FeatureNotSupported`] on gRPC transport.
217    /// - [`Error::Server`] if the `UPDATE`/`INSERT` fails.
218    pub fn set(&self, key: &str, value: &str) -> Result<SetOutcome> {
219        validate_kv_name(key, "key")?;
220        Ok(SetOutcome {
221            created: self.upsert(key, value)?,
222        })
223    }
224
225    /// UPDATE-then-conditional-INSERT upsert. Assumes `key` is validated.
226    /// Returns `true` if the row was newly inserted (created), `false` if an
227    /// existing value was overwritten.
228    ///
229    /// Hyper has no `ON CONFLICT`; this mirrors the proven `_table_catalog`
230    /// idiom. The conditional INSERT uses distinct placeholders (`$4`/`$5`)
231    /// so it is unambiguous under the extended-query protocol.
232    fn upsert(&self, key: &str, value: &str) -> Result<bool> {
233        let store = self.store_name.as_str();
234        let updated = self.connection.command_params(
235            &format!(
236                "UPDATE {} SET value = $3 WHERE store_name = $1 AND key = $2",
237                self.table_ref
238            ),
239            &[&store, &key, &value],
240        )?;
241        if updated == 0 {
242            self.connection.command_params(
243                &format!(
244                    "INSERT INTO {t} (store_name, key, value) \
245                     SELECT $1, $2, $3 \
246                     WHERE NOT EXISTS (SELECT 1 FROM {t} WHERE store_name = $4 AND key = $5)",
247                    t = self.table_ref
248                ),
249                &[&store, &key, &value, &store, &key],
250            )?;
251        }
252        Ok(updated == 0)
253    }
254
255    /// Deserializes the JSON-encoded value for `key` into `T`.
256    ///
257    /// Returns `None` if the key is absent.
258    ///
259    /// # Errors
260    ///
261    /// - [`Error::InvalidName`] if `key` is invalid.
262    /// - [`Error::Serialization`] if the stored value is not valid JSON for `T`.
263    /// - [`Error::FeatureNotSupported`] / [`Error::Server`] as for [`get`](Self::get).
264    pub fn get_as<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
265        match self.get(key)? {
266            Some(json) => serde_json::from_str(&json)
267                .map(Some)
268                .map_err(|e| Error::serialization(e.to_string())),
269            None => Ok(None),
270        }
271    }
272
273    /// Serializes `value` to JSON and stores it under `key` (upsert). Returns
274    /// [`SetOutcome`] indicating whether the key was newly created.
275    ///
276    /// # Errors
277    ///
278    /// - [`Error::InvalidName`] if `key` is invalid.
279    /// - [`Error::Serialization`] if `value` cannot be serialized to JSON.
280    /// - [`Error::FeatureNotSupported`] / [`Error::Server`] as for [`set`](Self::set).
281    pub fn set_as<T: serde::Serialize>(&self, key: &str, value: &T) -> Result<SetOutcome> {
282        validate_kv_name(key, "key")?;
283        let json = serde_json::to_string(value).map_err(|e| Error::serialization(e.to_string()))?;
284        Ok(SetOutcome {
285            created: self.upsert(key, &json)?,
286        })
287    }
288
289    /// Inserts `value` under `key` only if `key` is absent.
290    ///
291    /// Returns `true` if a row was written, `false` if the key already existed
292    /// (in which case nothing is written). A single `INSERT ... WHERE NOT
293    /// EXISTS` statement decides, so there is no check-then-write race within a
294    /// connection. The backing table has no unique constraint on
295    /// `(store_name, key)`, so two *separate* processes writing the same key to
296    /// a shared persistent store concurrently could both pass `NOT EXISTS` and
297    /// double-insert; within a single process (including the MCP daemon, which
298    /// serializes engine access) the guard is exact.
299    ///
300    /// # Errors
301    ///
302    /// - [`Error::InvalidName`] if `key` is invalid.
303    /// - [`Error::FeatureNotSupported`] on gRPC transport.
304    /// - [`Error::Server`] if the `INSERT` fails.
305    pub fn set_if_absent(&self, key: &str, value: &str) -> Result<bool> {
306        validate_kv_name(key, "key")?;
307        let store = self.store_name.as_str();
308        let inserted = self.connection.command_params(
309            &format!(
310                "INSERT INTO {t} (store_name, key, value) \
311                 SELECT $1, $2, $3 \
312                 WHERE NOT EXISTS (SELECT 1 FROM {t} WHERE store_name = $4 AND key = $5)",
313                t = self.table_ref
314            ),
315            &[&store, &key, &value, &store, &key],
316        )?;
317        Ok(inserted > 0)
318    }
319
320    /// Deletes `key`; returns `true` if a row was removed.
321    ///
322    /// # Errors
323    ///
324    /// - [`Error::InvalidName`] if `key` is invalid.
325    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
326    pub fn delete(&self, key: &str) -> Result<bool> {
327        validate_kv_name(key, "key")?;
328        let affected = self.connection.command_params(
329            &format!(
330                "DELETE FROM {} WHERE store_name = $1 AND key = $2",
331                self.table_ref
332            ),
333            &[&self.store_name.as_str(), &key],
334        )?;
335        Ok(affected > 0)
336    }
337
338    /// Returns whether `key` is present in this store.
339    ///
340    /// # Errors
341    ///
342    /// - [`Error::InvalidName`] if `key` is invalid.
343    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
344    pub fn exists(&self, key: &str) -> Result<bool> {
345        validate_kv_name(key, "key")?;
346        let sql = format!(
347            "SELECT 1 FROM {} WHERE store_name = $1 AND key = $2 LIMIT 1",
348            self.table_ref
349        );
350        Ok(self
351            .connection
352            .query_params(&sql, &[&self.store_name.as_str(), &key])?
353            .first_row()?
354            .is_some())
355    }
356
357    /// Returns the number of keys in this store.
358    ///
359    /// # Errors
360    ///
361    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
362    pub fn size(&self) -> Result<i64> {
363        let sql = format!(
364            "SELECT COUNT(*) FROM {} WHERE store_name = $1",
365            self.table_ref
366        );
367        // `scalar()` errors on zero rows, but COUNT(*) always returns exactly
368        // one non-NULL row, so `unwrap_or(0)` is unreachable-but-defensive.
369        Ok(self
370            .connection
371            .query_params(&sql, &[&self.store_name.as_str()])?
372            .scalar::<i64>()?
373            .unwrap_or(0))
374    }
375
376    /// Returns this store's keys, sorted ascending.
377    ///
378    /// # Errors
379    ///
380    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
381    pub fn keys(&self) -> Result<Vec<String>> {
382        let sql = format!(
383            "SELECT key FROM {} WHERE store_name = $1 ORDER BY key ASC",
384            self.table_ref
385        );
386        let mut result = self
387            .connection
388            .query_params(&sql, &[&self.store_name.as_str()])?;
389        let mut keys = Vec::new();
390        while let Some(chunk) = result.next_chunk()? {
391            for row in &chunk {
392                if let Some(k) = row.get::<String>(0) {
393                    keys.push(k);
394                }
395            }
396        }
397        Ok(keys)
398    }
399
400    /// Returns the total byte length of all values in this store
401    /// (`SUM(OCTET_LENGTH(value))`). Returns 0 for an empty store; `NULL`
402    /// values contribute 0 via `COALESCE`.
403    ///
404    /// # Errors
405    ///
406    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
407    pub fn byte_size(&self) -> Result<i64> {
408        let sql = format!(
409            "SELECT COALESCE(SUM(OCTET_LENGTH(value)), 0) FROM {} WHERE store_name = $1",
410            self.table_ref
411        );
412        Ok(self
413            .connection
414            .query_params(&sql, &[&self.store_name.as_str()])?
415            .scalar::<i64>()?
416            .unwrap_or(0))
417    }
418
419    /// Returns this store's `(key, value)` pairs, sorted by key ascending.
420    ///
421    /// Materializes the whole store — intended for small scratchpad stores.
422    ///
423    /// # Errors
424    ///
425    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
426    pub fn entries(&self) -> Result<Vec<(String, String)>> {
427        let sql = format!(
428            "SELECT key, value FROM {} WHERE store_name = $1 ORDER BY key ASC",
429            self.table_ref
430        );
431        let mut result = self
432            .connection
433            .query_params(&sql, &[&self.store_name.as_str()])?;
434        let mut entries = Vec::new();
435        while let Some(chunk) = result.next_chunk()? {
436            for row in &chunk {
437                if let Some(k) = row.get::<String>(0) {
438                    entries.push((k, row.get::<String>(1).unwrap_or_default()));
439                }
440            }
441        }
442        Ok(entries)
443    }
444
445    /// Deletes every key in this store; returns the number removed.
446    ///
447    /// The shared backing table survives; only this store's rows are removed.
448    ///
449    /// # Errors
450    ///
451    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
452    pub fn clear(&self) -> Result<u64> {
453        self.connection.command_params(
454            &format!("DELETE FROM {} WHERE store_name = $1", self.table_ref),
455            &[&self.store_name.as_str()],
456        )
457    }
458
459    /// Removes and returns the lowest-ordered key/value pair, or `None` if empty.
460    ///
461    /// The peek and delete run in one transaction, so they apply atomically —
462    /// either both the read and the delete commit, or neither does (on error
463    /// the transaction is rolled back). A SQL-NULL value is returned as an
464    /// empty string.
465    ///
466    /// # Errors
467    ///
468    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
469    pub fn pop(&self) -> Result<Option<(String, String)>> {
470        self.connection.begin_transaction_raw()?;
471        let result = self.pop_inner();
472        match &result {
473            Ok(_) => self.connection.commit_raw()?,
474            Err(_) => {
475                // Best-effort rollback; preserve the original error.
476                let _ = self.connection.rollback_raw();
477            }
478        }
479        result
480    }
481
482    /// Transaction body for [`pop`](Self::pop).
483    fn pop_inner(&self) -> Result<Option<(String, String)>> {
484        let store = self.store_name.as_str();
485        let select = format!(
486            "SELECT key, value FROM {} WHERE store_name = $1 ORDER BY key ASC LIMIT 1",
487            self.table_ref
488        );
489        // `first_row()` consumes the `Rowset`, dropping it (and releasing its
490        // statement guard on the shared connection) BEFORE the DELETE runs —
491        // the two statements never overlap on the connection.
492        let Some(row) = self
493            .connection
494            .query_params(&select, &[&store])?
495            .first_row()?
496        else {
497            return Ok(None);
498        };
499        let key: String = row
500            .get::<String>(0)
501            .ok_or_else(|| Error::internal("kv pop: key column was unexpectedly NULL"))?;
502        let value: String = row.get::<String>(1).unwrap_or_default();
503        self.connection.command_params(
504            &format!(
505                "DELETE FROM {} WHERE store_name = $1 AND key = $2",
506                self.table_ref
507            ),
508            &[&store, &key.as_str()],
509        )?;
510        Ok(Some((key, value)))
511    }
512
513    /// Upserts every `(key, value)` pair in one transaction. Returns
514    /// [`BatchSetOutcome`] reporting how many keys were newly inserted vs.
515    /// overwritten.
516    ///
517    /// All keys are validated before the transaction opens, so an invalid key
518    /// aborts the whole batch without writing anything.
519    ///
520    /// # Errors
521    ///
522    /// - [`Error::InvalidName`] if any key is invalid (checked before writing).
523    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
524    pub fn set_batch(&self, entries: &[(&str, &str)]) -> Result<BatchSetOutcome> {
525        for (key, _) in entries {
526            validate_kv_name(key, "key")?;
527        }
528        self.connection.begin_transaction_raw()?;
529        let result = (|| {
530            let mut outcome = BatchSetOutcome {
531                created: 0,
532                overwritten: 0,
533            };
534            for (key, value) in entries {
535                if self.upsert(key, value)? {
536                    outcome.created += 1;
537                } else {
538                    outcome.overwritten += 1;
539                }
540            }
541            Ok(outcome)
542        })();
543        match &result {
544            Ok(_) => self.connection.commit_raw()?,
545            Err(_) => {
546                let _ = self.connection.rollback_raw();
547            }
548        }
549        result
550    }
551
552    /// Inserts every absent `(key, value)` pair in one transaction, skipping
553    /// keys that already exist. All keys are validated before the transaction
554    /// opens, so an invalid key aborts the whole batch without writing anything.
555    ///
556    /// # Errors
557    ///
558    /// - [`Error::InvalidName`] if any key is invalid (checked before writing).
559    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
560    pub fn set_batch_if_absent(&self, entries: &[(&str, &str)]) -> Result<BatchGuardOutcome> {
561        for (key, _) in entries {
562            validate_kv_name(key, "key")?;
563        }
564        self.connection.begin_transaction_raw()?;
565        let result = (|| {
566            let mut outcome = BatchGuardOutcome {
567                written: 0,
568                skipped: 0,
569            };
570            for (key, value) in entries {
571                if self.set_if_absent(key, value)? {
572                    outcome.written += 1;
573                } else {
574                    outcome.skipped += 1;
575                }
576            }
577            Ok(outcome)
578        })();
579        match &result {
580            Ok(_) => self.connection.commit_raw()?,
581            Err(_) => {
582                let _ = self.connection.rollback_raw();
583            }
584        }
585        result
586    }
587}
588
589impl Connection {
590    /// Opens a handle to a named key-value store, creating the table if needed.
591    ///
592    /// # Examples
593    ///
594    /// ```no_run
595    /// # use hyperdb_api::{Connection, CreateMode, Result};
596    /// # fn example(conn: &Connection) -> Result<()> {
597    /// let kv = conn.kv_store("session")?;
598    /// # Ok(())
599    /// # }
600    /// ```
601    ///
602    /// # Errors
603    ///
604    /// - [`Error::InvalidName`] if `name` is empty, too long, or has invalid characters.
605    /// - [`Error::FeatureNotSupported`] on gRPC transport.
606    /// - [`Error::Server`] if the `CREATE TABLE IF NOT EXISTS` fails.
607    pub fn kv_store(&self, name: &str) -> Result<KvStore<'_>> {
608        KvStore::new(self, name)
609    }
610
611    /// Opens a handle to a KV store in a specific database, rather than the
612    /// default (search-path) location.
613    ///
614    /// `database` is the **unescaped** name of an attached database; the store's
615    /// backing table is created (if absent) in that database's `public` schema.
616    /// The name is identifier-escaped internally, so it is safe to pass an
617    /// arbitrary attachment alias. The store name, keys, and values are always
618    /// bound parameters.
619    ///
620    /// # Examples
621    ///
622    /// ```no_run
623    /// # use hyperdb_api::{Connection, CreateMode, Result};
624    /// # fn example(conn: &Connection) -> Result<()> {
625    /// let kv = conn.kv_store_in("persistent", "settings")?;
626    /// kv.set("theme", "dark")?;
627    /// # Ok(())
628    /// # }
629    /// ```
630    ///
631    /// # Errors
632    ///
633    /// - [`Error::InvalidName`] if `database` or `name` is invalid.
634    /// - [`Error::FeatureNotSupported`] on gRPC transport.
635    /// - [`Error::Server`] if creating the backing table fails.
636    pub fn kv_store_in(&self, database: &str, name: &str) -> Result<KvStore<'_>> {
637        KvStore::with_target(self, name, &kv_target_prefix(database)?)
638    }
639
640    /// Lists the names of every KV store that currently holds at least one key.
641    ///
642    /// Creates the backing table first (via `kv_create_table_sql`) so calling
643    /// this on a fresh database returns an empty list rather than erroring on a
644    /// missing table.
645    ///
646    /// # Errors
647    ///
648    /// - [`Error::FeatureNotSupported`] on gRPC transport.
649    /// - [`Error::Server`] if the query fails.
650    pub fn kv_list_stores(&self) -> Result<Vec<String>> {
651        self.kv_list_stores_impl(KV_TABLE)
652    }
653
654    /// Lists the KV stores that hold at least one key in a specific database.
655    ///
656    /// The location-aware companion to [`kv_list_stores`](Self::kv_list_stores):
657    /// `database` is the unescaped name of an attached database (escaped
658    /// internally). Creates the backing table in that database's `public` schema
659    /// first, so an empty database returns `[]` rather than erroring.
660    ///
661    /// # Errors
662    ///
663    /// - [`Error::InvalidName`] if `database` is invalid.
664    /// - [`Error::FeatureNotSupported`] on gRPC transport.
665    /// - [`Error::Server`] if the query fails.
666    pub fn kv_list_stores_in(&self, database: &str) -> Result<Vec<String>> {
667        let table_ref = format!("{}.{KV_TABLE}", kv_target_prefix(database)?);
668        self.kv_list_stores_impl(&table_ref)
669    }
670
671    /// Shared body for [`kv_list_stores`](Self::kv_list_stores) /
672    /// [`kv_list_stores_in`](Self::kv_list_stores_in): create the backing table
673    /// at `table_ref` (so a fresh location returns `[]`), then list the distinct
674    /// store names there. Factored out so the default and location-aware paths
675    /// cannot drift.
676    fn kv_list_stores_impl(&self, table_ref: &str) -> Result<Vec<String>> {
677        self.execute_command(&kv_create_table_sql(table_ref))?;
678        let mut result = self.execute_query(&format!(
679            "SELECT DISTINCT store_name FROM {table_ref} ORDER BY store_name ASC"
680        ))?;
681        let mut names = Vec::new();
682        while let Some(chunk) = result.next_chunk()? {
683            for row in &chunk {
684                if let Some(name) = row.get::<String>(0) {
685                    names.push(name);
686                }
687            }
688        }
689        Ok(names)
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn accepts_valid_names() {
699        for ok in [
700            "a",
701            "store_1",
702            "my.key-2",
703            "A",
704            &"z".repeat(KV_MAX_NAME_BYTES),
705        ] {
706            assert!(validate_kv_name(ok, "key").is_ok(), "should accept {ok:?}");
707        }
708    }
709
710    #[test]
711    fn rejects_empty() {
712        let err = validate_kv_name("", "store name").unwrap_err();
713        assert!(matches!(err, Error::InvalidName(_)));
714        assert!(err.to_string().contains("must not be empty"));
715    }
716
717    #[test]
718    fn rejects_too_long() {
719        let long = "a".repeat(KV_MAX_NAME_BYTES + 1);
720        let err = validate_kv_name(&long, "key").unwrap_err();
721        assert!(matches!(err, Error::InvalidName(_)));
722        assert!(err.to_string().contains("byte limit"));
723    }
724
725    #[test]
726    fn rejects_bad_charset() {
727        for bad in ["a b", "a/b", "a'b", "a\"b", "a;b", "naïve", "a\0b"] {
728            let err = validate_kv_name(bad, "key").unwrap_err();
729            assert!(
730                matches!(err, Error::InvalidName(_)),
731                "should reject {bad:?}"
732            );
733        }
734    }
735}