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}