Skip to main content

hyperdb_api/
async_kv_store.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Async key-value store — the [`AsyncConnection`] twin of [`KvStore`](crate::KvStore).
5
6use crate::async_connection::AsyncConnection;
7use crate::error::{Error, Result};
8use crate::kv_store::{
9    kv_create_table_sql, kv_target_prefix, validate_kv_name, BatchGuardOutcome, BatchSetOutcome,
10    SetOutcome, KV_TABLE,
11};
12
13/// A handle to one named key-value store over an [`AsyncConnection`].
14///
15/// The async twin of [`KvStore`](crate::KvStore); see it for semantics. Open
16/// one with [`AsyncConnection::kv_store`].
17///
18/// # Examples
19///
20/// ```no_run
21/// use hyperdb_api::{AsyncConnection, CreateMode, Result};
22///
23/// async fn demo(conn: &AsyncConnection) -> Result<()> {
24///     let kv = conn.kv_store("settings").await?;
25///     kv.set("theme", "dark").await?;
26///     assert_eq!(kv.get("theme").await?, Some("dark".to_string()));
27///     Ok(())
28/// }
29/// ```
30#[derive(Debug)]
31pub struct AsyncKvStore<'conn> {
32    connection: &'conn AsyncConnection,
33    store_name: String,
34    table_ref: String,
35}
36
37impl<'conn> AsyncKvStore<'conn> {
38    /// Opens a handle to `name`, creating `KV_TABLE` if needed.
39    async fn open(
40        connection: &'conn AsyncConnection,
41        name: &str,
42        table_ref: String,
43    ) -> Result<Self> {
44        validate_kv_name(name, "store name")?;
45        connection
46            .execute_command(&kv_create_table_sql(&table_ref))
47            .await?;
48        Ok(AsyncKvStore {
49            connection,
50            store_name: name.to_string(),
51            table_ref,
52        })
53    }
54
55    /// Opens a handle to a store in the default location.
56    pub(crate) async fn new(connection: &'conn AsyncConnection, name: &str) -> Result<Self> {
57        Self::open(connection, name, KV_TABLE.to_string()).await
58    }
59
60    /// Async twin of [`KvStore::with_target`](crate::KvStore::with_target).
61    ///
62    /// Crate-internal low-level constructor behind
63    /// [`AsyncConnection::kv_store_in`](crate::AsyncConnection::kv_store_in).
64    /// `target` is interpolated into SQL — the caller must supply a pre-escaped,
65    /// SQL-safe qualifier (public callers go through `kv_store_in`, which
66    /// escapes for them).
67    pub(crate) async fn with_target(
68        connection: &'conn AsyncConnection,
69        name: &str,
70        target: &str,
71    ) -> Result<Self> {
72        Self::open(connection, name, format!("{target}.{KV_TABLE}")).await
73    }
74
75    /// Returns this store's validated name.
76    #[must_use]
77    pub fn name(&self) -> &str {
78        &self.store_name
79    }
80
81    /// Returns the value for `key`, or `None` if absent or NULL.
82    ///
83    /// # Errors
84    ///
85    /// See [`KvStore::get`](crate::KvStore::get).
86    pub async fn get(&self, key: &str) -> Result<Option<String>> {
87        validate_kv_name(key, "key")?;
88        let sql = format!(
89            "SELECT value FROM {} WHERE store_name = $1 AND key = $2",
90            self.table_ref
91        );
92        // Bind store_name/key as `&str` params (never interpolated) — uniform
93        // `&str` element types coerce cleanly to `&[&dyn ToSqlParam]`.
94        let row = self
95            .connection
96            .query_params(&sql, &[&self.store_name.as_str(), &key])
97            .await?
98            .first_row()
99            .await?;
100        Ok(row.and_then(|r| r.get::<String>(0)))
101    }
102
103    /// Sets `key` to `value`, inserting or overwriting (upsert). Returns
104    /// [`SetOutcome`] indicating whether the key was newly created.
105    ///
106    /// # Errors
107    ///
108    /// See [`KvStore::set`](crate::KvStore::set).
109    pub async fn set(&self, key: &str, value: &str) -> Result<SetOutcome> {
110        validate_kv_name(key, "key")?;
111        Ok(SetOutcome {
112            created: self.upsert(key, value).await?,
113        })
114    }
115
116    /// UPDATE-then-conditional-INSERT upsert. Assumes `key` is validated.
117    /// Returns `true` if the row was newly inserted (created), `false` if an
118    /// existing value was overwritten.
119    ///
120    /// Mirrors [`KvStore::upsert`](crate::KvStore); the conditional INSERT uses
121    /// distinct placeholders (`$4`/`$5`) so it is unambiguous under the
122    /// extended-query protocol.
123    async fn upsert(&self, key: &str, value: &str) -> Result<bool> {
124        let updated = self
125            .connection
126            .command_params(
127                &format!(
128                    "UPDATE {} SET value = $3 WHERE store_name = $1 AND key = $2",
129                    self.table_ref
130                ),
131                &[&self.store_name.as_str(), &key, &value],
132            )
133            .await?;
134        if updated == 0 {
135            self.connection
136                .command_params(
137                    &format!(
138                        "INSERT INTO {t} (store_name, key, value) \
139                         SELECT $1, $2, $3 \
140                         WHERE NOT EXISTS (SELECT 1 FROM {t} WHERE store_name = $4 AND key = $5)",
141                        t = self.table_ref
142                    ),
143                    &[
144                        &self.store_name.as_str(),
145                        &key,
146                        &value,
147                        &self.store_name.as_str(),
148                        &key,
149                    ],
150                )
151                .await?;
152        }
153        Ok(updated == 0)
154    }
155
156    /// Deserializes the JSON value for `key` into `T`; `None` if absent.
157    ///
158    /// # Errors
159    ///
160    /// See [`KvStore::get_as`](crate::KvStore::get_as).
161    pub async fn get_as<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
162        match self.get(key).await? {
163            Some(json) => serde_json::from_str(&json)
164                .map(Some)
165                .map_err(|e| Error::serialization(e.to_string())),
166            None => Ok(None),
167        }
168    }
169
170    /// Serializes `value` to JSON and stores it under `key` (upsert). Returns
171    /// [`SetOutcome`] indicating whether the key was newly created.
172    ///
173    /// # Errors
174    ///
175    /// See [`KvStore::set_as`](crate::KvStore::set_as).
176    pub async fn set_as<T: serde::Serialize>(&self, key: &str, value: &T) -> Result<SetOutcome> {
177        validate_kv_name(key, "key")?;
178        let json = serde_json::to_string(value).map_err(|e| Error::serialization(e.to_string()))?;
179        Ok(SetOutcome {
180            created: self.upsert(key, &json).await?,
181        })
182    }
183
184    /// Inserts `value` under `key` only if `key` is absent.
185    ///
186    /// Returns `true` if a row was written, `false` if the key already existed
187    /// (in which case nothing is written). A single `INSERT ... WHERE NOT
188    /// EXISTS` statement decides, so there is no check-then-write race within a
189    /// connection. The backing table has no unique constraint on
190    /// `(store_name, key)`, so two *separate* processes writing the same key to
191    /// a shared persistent store concurrently could both pass `NOT EXISTS` and
192    /// double-insert; within a single process (including the MCP daemon, which
193    /// serializes engine access) the guard is exact.
194    ///
195    /// # Errors
196    ///
197    /// - [`Error::InvalidName`] if `key` is invalid.
198    /// - [`Error::FeatureNotSupported`] on gRPC transport.
199    /// - [`Error::Server`] if the `INSERT` fails.
200    pub async fn set_if_absent(&self, key: &str, value: &str) -> Result<bool> {
201        validate_kv_name(key, "key")?;
202        let store = self.store_name.as_str();
203        let inserted = self
204            .connection
205            .command_params(
206                &format!(
207                    "INSERT INTO {t} (store_name, key, value) \
208                     SELECT $1, $2, $3 \
209                     WHERE NOT EXISTS (SELECT 1 FROM {t} WHERE store_name = $4 AND key = $5)",
210                    t = self.table_ref
211                ),
212                &[&store, &key, &value, &store, &key],
213            )
214            .await?;
215        Ok(inserted > 0)
216    }
217
218    /// Deletes `key`; returns `true` if a row was removed.
219    ///
220    /// # Errors
221    ///
222    /// See [`KvStore::delete`](crate::KvStore::delete).
223    pub async fn delete(&self, key: &str) -> Result<bool> {
224        validate_kv_name(key, "key")?;
225        let affected = self
226            .connection
227            .command_params(
228                &format!(
229                    "DELETE FROM {} WHERE store_name = $1 AND key = $2",
230                    self.table_ref
231                ),
232                &[&self.store_name.as_str(), &key],
233            )
234            .await?;
235        Ok(affected > 0)
236    }
237
238    /// Returns whether `key` is present.
239    ///
240    /// # Errors
241    ///
242    /// See [`KvStore::exists`](crate::KvStore::exists).
243    pub async fn exists(&self, key: &str) -> Result<bool> {
244        validate_kv_name(key, "key")?;
245        let sql = format!(
246            "SELECT 1 FROM {} WHERE store_name = $1 AND key = $2 LIMIT 1",
247            self.table_ref
248        );
249        Ok(self
250            .connection
251            .query_params(&sql, &[&self.store_name.as_str(), &key])
252            .await?
253            .first_row()
254            .await?
255            .is_some())
256    }
257
258    /// Returns the number of keys in this store.
259    ///
260    /// # Errors
261    ///
262    /// See [`KvStore::size`](crate::KvStore::size).
263    pub async fn size(&self) -> Result<i64> {
264        let sql = format!(
265            "SELECT COUNT(*) FROM {} WHERE store_name = $1",
266            self.table_ref
267        );
268        // `scalar()` errors on zero rows, but COUNT(*) always returns exactly
269        // one non-NULL row, so `unwrap_or(0)` is unreachable-but-defensive.
270        Ok(self
271            .connection
272            .query_params(&sql, &[&self.store_name.as_str()])
273            .await?
274            .scalar::<i64>()
275            .await?
276            .unwrap_or(0))
277    }
278
279    /// Returns the total byte size of all values in this store (0 if empty).
280    ///
281    /// # Errors
282    ///
283    /// See [`KvStore::byte_size`](crate::KvStore::byte_size).
284    pub async fn byte_size(&self) -> Result<i64> {
285        let sql = format!(
286            "SELECT COALESCE(SUM(OCTET_LENGTH(value)), 0) FROM {} WHERE store_name = $1",
287            self.table_ref
288        );
289        Ok(self
290            .connection
291            .query_params(&sql, &[&self.store_name.as_str()])
292            .await?
293            .scalar::<i64>()
294            .await?
295            .unwrap_or(0))
296    }
297
298    /// Returns this store's `(key, value)` pairs, sorted by key ascending.
299    ///
300    /// Materializes the whole store — intended for small scratchpad stores.
301    ///
302    /// # Errors
303    ///
304    /// See [`KvStore::entries`](crate::KvStore::entries).
305    pub async fn entries(&self) -> Result<Vec<(String, String)>> {
306        let sql = format!(
307            "SELECT key, value FROM {} WHERE store_name = $1 ORDER BY key ASC",
308            self.table_ref
309        );
310        let mut result = self
311            .connection
312            .query_params(&sql, &[&self.store_name.as_str()])
313            .await?;
314        let mut entries = Vec::new();
315        while let Some(chunk) = result.next_chunk().await? {
316            for row in &chunk {
317                if let Some(k) = row.get::<String>(0) {
318                    entries.push((k, row.get::<String>(1).unwrap_or_default()));
319                }
320            }
321        }
322        Ok(entries)
323    }
324
325    /// Returns this store's keys, sorted ascending.
326    ///
327    /// # Errors
328    ///
329    /// See [`KvStore::keys`](crate::KvStore::keys).
330    pub async fn keys(&self) -> Result<Vec<String>> {
331        let sql = format!(
332            "SELECT key FROM {} WHERE store_name = $1 ORDER BY key ASC",
333            self.table_ref
334        );
335        let mut result = self
336            .connection
337            .query_params(&sql, &[&self.store_name.as_str()])
338            .await?;
339        let mut keys = Vec::new();
340        while let Some(chunk) = result.next_chunk().await? {
341            for row in &chunk {
342                if let Some(k) = row.get::<String>(0) {
343                    keys.push(k);
344                }
345            }
346        }
347        Ok(keys)
348    }
349
350    /// Deletes every key in this store; returns the number removed.
351    ///
352    /// The shared backing table survives; only this store's rows are removed.
353    ///
354    /// # Errors
355    ///
356    /// See [`KvStore::clear`](crate::KvStore::clear).
357    pub async fn clear(&self) -> Result<u64> {
358        self.connection
359            .command_params(
360                &format!("DELETE FROM {} WHERE store_name = $1", self.table_ref),
361                &[&self.store_name.as_str()],
362            )
363            .await
364    }
365
366    /// Removes and returns the lowest-ordered pair, or `None` if empty.
367    ///
368    /// The peek and delete run in one transaction, so they apply atomically —
369    /// either both commit, or neither does (on error the transaction is rolled
370    /// back). A SQL-NULL value is returned as an empty string.
371    ///
372    /// # Errors
373    ///
374    /// See [`KvStore::pop`](crate::KvStore::pop).
375    pub async fn pop(&self) -> Result<Option<(String, String)>> {
376        self.connection.begin_transaction_raw().await?;
377        let result = self.pop_inner().await;
378        match &result {
379            Ok(_) => self.connection.commit_raw().await?,
380            Err(_) => {
381                // Best-effort rollback; preserve the original error.
382                let _ = self.connection.rollback_raw().await;
383            }
384        }
385        result
386    }
387
388    /// Transaction body for [`pop`](Self::pop).
389    async fn pop_inner(&self) -> Result<Option<(String, String)>> {
390        let select = format!(
391            "SELECT key, value FROM {} WHERE store_name = $1 ORDER BY key ASC LIMIT 1",
392            self.table_ref
393        );
394        // `first_row()` consumes the `AsyncRowset`, releasing its statement
395        // guard on the shared connection BEFORE the DELETE runs — the two
396        // statements never overlap on the connection.
397        let Some(row) = self
398            .connection
399            .query_params(&select, &[&self.store_name.as_str()])
400            .await?
401            .first_row()
402            .await?
403        else {
404            return Ok(None);
405        };
406        let key: String = row
407            .get::<String>(0)
408            .ok_or_else(|| Error::internal("kv pop: key column was unexpectedly NULL"))?;
409        let value: String = row.get::<String>(1).unwrap_or_default();
410        self.connection
411            .command_params(
412                &format!(
413                    "DELETE FROM {} WHERE store_name = $1 AND key = $2",
414                    self.table_ref
415                ),
416                &[&self.store_name.as_str(), &key.as_str()],
417            )
418            .await?;
419        Ok(Some((key, value)))
420    }
421
422    /// Upserts every `(key, value)` pair in one transaction. Returns
423    /// [`BatchSetOutcome`] reporting how many keys were newly inserted vs.
424    /// overwritten.
425    ///
426    /// All keys are validated before the transaction opens, so an invalid key
427    /// aborts the whole batch without writing anything.
428    ///
429    /// # Errors
430    ///
431    /// See [`KvStore::set_batch`](crate::KvStore::set_batch).
432    pub async fn set_batch(&self, entries: &[(&str, &str)]) -> Result<BatchSetOutcome> {
433        for (key, _) in entries {
434            validate_kv_name(key, "key")?;
435        }
436        self.connection.begin_transaction_raw().await?;
437        let result = async {
438            let mut outcome = BatchSetOutcome {
439                created: 0,
440                overwritten: 0,
441            };
442            for (key, value) in entries {
443                if self.upsert(key, value).await? {
444                    outcome.created += 1;
445                } else {
446                    outcome.overwritten += 1;
447                }
448            }
449            Ok(outcome)
450        }
451        .await;
452        match &result {
453            Ok(_) => self.connection.commit_raw().await?,
454            Err(_) => {
455                let _ = self.connection.rollback_raw().await;
456            }
457        }
458        result
459    }
460
461    /// Inserts every absent `(key, value)` pair in one transaction, skipping
462    /// keys that already exist. All keys are validated before the transaction
463    /// opens, so an invalid key aborts the whole batch without writing anything.
464    ///
465    /// # Errors
466    ///
467    /// - [`Error::InvalidName`] if any key is invalid (checked before writing).
468    /// - [`Error::FeatureNotSupported`] / [`Error::Server`].
469    pub async fn set_batch_if_absent(&self, entries: &[(&str, &str)]) -> Result<BatchGuardOutcome> {
470        for (key, _) in entries {
471            validate_kv_name(key, "key")?;
472        }
473        self.connection.begin_transaction_raw().await?;
474        let mut inner: Result<BatchGuardOutcome> = Ok(BatchGuardOutcome {
475            written: 0,
476            skipped: 0,
477        });
478        for (key, value) in entries {
479            match self.set_if_absent(key, value).await {
480                Ok(true) => {
481                    if let Ok(o) = inner.as_mut() {
482                        o.written += 1;
483                    }
484                }
485                Ok(false) => {
486                    if let Ok(o) = inner.as_mut() {
487                        o.skipped += 1;
488                    }
489                }
490                Err(e) => {
491                    inner = Err(e);
492                    break;
493                }
494            }
495        }
496        match &inner {
497            Ok(_) => self.connection.commit_raw().await?,
498            Err(_) => {
499                let _ = self.connection.rollback_raw().await;
500            }
501        }
502        inner
503    }
504}
505
506impl AsyncConnection {
507    /// Opens a handle to a named KV store, creating the table if needed.
508    ///
509    /// # Errors
510    ///
511    /// See [`Connection::kv_store`](crate::Connection::kv_store).
512    pub async fn kv_store(&self, name: &str) -> Result<AsyncKvStore<'_>> {
513        AsyncKvStore::new(self, name).await
514    }
515
516    /// Async twin of [`Connection::kv_store_in`](crate::Connection::kv_store_in).
517    ///
518    /// # Errors
519    ///
520    /// See [`Connection::kv_store_in`](crate::Connection::kv_store_in).
521    pub async fn kv_store_in(&self, database: &str, name: &str) -> Result<AsyncKvStore<'_>> {
522        AsyncKvStore::with_target(self, name, &kv_target_prefix(database)?).await
523    }
524
525    /// Lists the names of every KV store that currently holds at least one key.
526    ///
527    /// # Errors
528    ///
529    /// See [`Connection::kv_list_stores`](crate::Connection::kv_list_stores).
530    pub async fn kv_list_stores(&self) -> Result<Vec<String>> {
531        self.kv_list_stores_impl(KV_TABLE).await
532    }
533
534    /// Async twin of
535    /// [`Connection::kv_list_stores_in`](crate::Connection::kv_list_stores_in).
536    ///
537    /// # Errors
538    ///
539    /// See [`Connection::kv_list_stores_in`](crate::Connection::kv_list_stores_in).
540    pub async fn kv_list_stores_in(&self, database: &str) -> Result<Vec<String>> {
541        let table_ref = format!("{}.{KV_TABLE}", kv_target_prefix(database)?);
542        self.kv_list_stores_impl(&table_ref).await
543    }
544
545    /// Shared body for [`kv_list_stores`](Self::kv_list_stores) /
546    /// [`kv_list_stores_in`](Self::kv_list_stores_in): the async twin of the sync
547    /// `kv_list_stores_impl`. Factored out so the default and location-aware
548    /// paths cannot drift.
549    async fn kv_list_stores_impl(&self, table_ref: &str) -> Result<Vec<String>> {
550        self.execute_command(&kv_create_table_sql(table_ref))
551            .await?;
552        let mut result = self
553            .execute_query(&format!(
554                "SELECT DISTINCT store_name FROM {table_ref} ORDER BY store_name ASC"
555            ))
556            .await?;
557        let mut names = Vec::new();
558        while let Some(chunk) = result.next_chunk().await? {
559            for row in &chunk {
560                if let Some(name) = row.get::<String>(0) {
561                    names.push(name);
562                }
563            }
564        }
565        Ok(names)
566    }
567}