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::{kv_create_table_sql, kv_target_prefix, validate_kv_name, KV_TABLE};
9
10/// A handle to one named key-value store over an [`AsyncConnection`].
11///
12/// The async twin of [`KvStore`](crate::KvStore); see it for semantics. Open
13/// one with [`AsyncConnection::kv_store`].
14///
15/// # Examples
16///
17/// ```no_run
18/// use hyperdb_api::{AsyncConnection, CreateMode, Result};
19///
20/// async fn demo(conn: &AsyncConnection) -> Result<()> {
21///     let kv = conn.kv_store("settings").await?;
22///     kv.set("theme", "dark").await?;
23///     assert_eq!(kv.get("theme").await?, Some("dark".to_string()));
24///     Ok(())
25/// }
26/// ```
27#[derive(Debug)]
28pub struct AsyncKvStore<'conn> {
29    connection: &'conn AsyncConnection,
30    store_name: String,
31    table_ref: String,
32}
33
34impl<'conn> AsyncKvStore<'conn> {
35    /// Opens a handle to `name`, creating `KV_TABLE` if needed.
36    async fn open(
37        connection: &'conn AsyncConnection,
38        name: &str,
39        table_ref: String,
40    ) -> Result<Self> {
41        validate_kv_name(name, "store name")?;
42        connection
43            .execute_command(&kv_create_table_sql(&table_ref))
44            .await?;
45        Ok(AsyncKvStore {
46            connection,
47            store_name: name.to_string(),
48            table_ref,
49        })
50    }
51
52    /// Opens a handle to a store in the default location.
53    pub(crate) async fn new(connection: &'conn AsyncConnection, name: &str) -> Result<Self> {
54        Self::open(connection, name, KV_TABLE.to_string()).await
55    }
56
57    /// Async twin of [`KvStore::with_target`](crate::KvStore::with_target).
58    ///
59    /// Crate-internal low-level constructor behind
60    /// [`AsyncConnection::kv_store_in`](crate::AsyncConnection::kv_store_in).
61    /// `target` is interpolated into SQL — the caller must supply a pre-escaped,
62    /// SQL-safe qualifier (public callers go through `kv_store_in`, which
63    /// escapes for them).
64    pub(crate) async fn with_target(
65        connection: &'conn AsyncConnection,
66        name: &str,
67        target: &str,
68    ) -> Result<Self> {
69        Self::open(connection, name, format!("{target}.{KV_TABLE}")).await
70    }
71
72    /// Returns this store's validated name.
73    #[must_use]
74    pub fn name(&self) -> &str {
75        &self.store_name
76    }
77
78    /// Returns the value for `key`, or `None` if absent or NULL.
79    ///
80    /// # Errors
81    ///
82    /// See [`KvStore::get`](crate::KvStore::get).
83    pub async fn get(&self, key: &str) -> Result<Option<String>> {
84        validate_kv_name(key, "key")?;
85        let sql = format!(
86            "SELECT value FROM {} WHERE store_name = $1 AND key = $2",
87            self.table_ref
88        );
89        // Bind store_name/key as `&str` params (never interpolated) — uniform
90        // `&str` element types coerce cleanly to `&[&dyn ToSqlParam]`.
91        let row = self
92            .connection
93            .query_params(&sql, &[&self.store_name.as_str(), &key])
94            .await?
95            .first_row()
96            .await?;
97        Ok(row.and_then(|r| r.get::<String>(0)))
98    }
99
100    /// Sets `key` to `value` (upsert).
101    ///
102    /// # Errors
103    ///
104    /// See [`KvStore::set`](crate::KvStore::set).
105    pub async fn set(&self, key: &str, value: &str) -> Result<()> {
106        validate_kv_name(key, "key")?;
107        self.upsert(key, value).await
108    }
109
110    /// UPDATE-then-conditional-INSERT upsert. Assumes `key` is validated.
111    ///
112    /// Mirrors [`KvStore::upsert`](crate::KvStore); the conditional INSERT uses
113    /// distinct placeholders (`$4`/`$5`) so it is unambiguous under the
114    /// extended-query protocol.
115    async fn upsert(&self, key: &str, value: &str) -> Result<()> {
116        let updated = self
117            .connection
118            .command_params(
119                &format!(
120                    "UPDATE {} SET value = $3 WHERE store_name = $1 AND key = $2",
121                    self.table_ref
122                ),
123                &[&self.store_name.as_str(), &key, &value],
124            )
125            .await?;
126        if updated == 0 {
127            self.connection
128                .command_params(
129                    &format!(
130                        "INSERT INTO {t} (store_name, key, value) \
131                         SELECT $1, $2, $3 \
132                         WHERE NOT EXISTS (SELECT 1 FROM {t} WHERE store_name = $4 AND key = $5)",
133                        t = self.table_ref
134                    ),
135                    &[
136                        &self.store_name.as_str(),
137                        &key,
138                        &value,
139                        &self.store_name.as_str(),
140                        &key,
141                    ],
142                )
143                .await?;
144        }
145        Ok(())
146    }
147
148    /// Deserializes the JSON value for `key` into `T`; `None` if absent.
149    ///
150    /// # Errors
151    ///
152    /// See [`KvStore::get_as`](crate::KvStore::get_as).
153    pub async fn get_as<T: serde::de::DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
154        match self.get(key).await? {
155            Some(json) => serde_json::from_str(&json)
156                .map(Some)
157                .map_err(|e| Error::serialization(e.to_string())),
158            None => Ok(None),
159        }
160    }
161
162    /// Serializes `value` to JSON and stores it under `key` (upsert).
163    ///
164    /// # Errors
165    ///
166    /// See [`KvStore::set_as`](crate::KvStore::set_as).
167    pub async fn set_as<T: serde::Serialize>(&self, key: &str, value: &T) -> Result<()> {
168        validate_kv_name(key, "key")?;
169        let json = serde_json::to_string(value).map_err(|e| Error::serialization(e.to_string()))?;
170        self.upsert(key, &json).await
171    }
172
173    /// Deletes `key`; returns `true` if a row was removed.
174    ///
175    /// # Errors
176    ///
177    /// See [`KvStore::delete`](crate::KvStore::delete).
178    pub async fn delete(&self, key: &str) -> Result<bool> {
179        validate_kv_name(key, "key")?;
180        let affected = self
181            .connection
182            .command_params(
183                &format!(
184                    "DELETE FROM {} WHERE store_name = $1 AND key = $2",
185                    self.table_ref
186                ),
187                &[&self.store_name.as_str(), &key],
188            )
189            .await?;
190        Ok(affected > 0)
191    }
192
193    /// Returns whether `key` is present.
194    ///
195    /// # Errors
196    ///
197    /// See [`KvStore::exists`](crate::KvStore::exists).
198    pub async fn exists(&self, key: &str) -> Result<bool> {
199        validate_kv_name(key, "key")?;
200        let sql = format!(
201            "SELECT 1 FROM {} WHERE store_name = $1 AND key = $2 LIMIT 1",
202            self.table_ref
203        );
204        Ok(self
205            .connection
206            .query_params(&sql, &[&self.store_name.as_str(), &key])
207            .await?
208            .first_row()
209            .await?
210            .is_some())
211    }
212
213    /// Returns the number of keys in this store.
214    ///
215    /// # Errors
216    ///
217    /// See [`KvStore::size`](crate::KvStore::size).
218    pub async fn size(&self) -> Result<i64> {
219        let sql = format!(
220            "SELECT COUNT(*) FROM {} WHERE store_name = $1",
221            self.table_ref
222        );
223        // `scalar()` errors on zero rows, but COUNT(*) always returns exactly
224        // one non-NULL row, so `unwrap_or(0)` is unreachable-but-defensive.
225        Ok(self
226            .connection
227            .query_params(&sql, &[&self.store_name.as_str()])
228            .await?
229            .scalar::<i64>()
230            .await?
231            .unwrap_or(0))
232    }
233
234    /// Returns this store's keys, sorted ascending.
235    ///
236    /// # Errors
237    ///
238    /// See [`KvStore::keys`](crate::KvStore::keys).
239    pub async fn keys(&self) -> Result<Vec<String>> {
240        let sql = format!(
241            "SELECT key FROM {} WHERE store_name = $1 ORDER BY key ASC",
242            self.table_ref
243        );
244        let mut result = self
245            .connection
246            .query_params(&sql, &[&self.store_name.as_str()])
247            .await?;
248        let mut keys = Vec::new();
249        while let Some(chunk) = result.next_chunk().await? {
250            for row in &chunk {
251                if let Some(k) = row.get::<String>(0) {
252                    keys.push(k);
253                }
254            }
255        }
256        Ok(keys)
257    }
258
259    /// Deletes every key in this store; returns the number removed.
260    ///
261    /// The shared backing table survives; only this store's rows are removed.
262    ///
263    /// # Errors
264    ///
265    /// See [`KvStore::clear`](crate::KvStore::clear).
266    pub async fn clear(&self) -> Result<u64> {
267        self.connection
268            .command_params(
269                &format!("DELETE FROM {} WHERE store_name = $1", self.table_ref),
270                &[&self.store_name.as_str()],
271            )
272            .await
273    }
274
275    /// Removes and returns the lowest-ordered pair, or `None` if empty.
276    ///
277    /// The peek and delete run in one transaction, so they apply atomically —
278    /// either both commit, or neither does (on error the transaction is rolled
279    /// back). A SQL-NULL value is returned as an empty string.
280    ///
281    /// # Errors
282    ///
283    /// See [`KvStore::pop`](crate::KvStore::pop).
284    pub async fn pop(&self) -> Result<Option<(String, String)>> {
285        self.connection.begin_transaction_raw().await?;
286        let result = self.pop_inner().await;
287        match &result {
288            Ok(_) => self.connection.commit_raw().await?,
289            Err(_) => {
290                // Best-effort rollback; preserve the original error.
291                let _ = self.connection.rollback_raw().await;
292            }
293        }
294        result
295    }
296
297    /// Transaction body for [`pop`](Self::pop).
298    async fn pop_inner(&self) -> Result<Option<(String, String)>> {
299        let select = format!(
300            "SELECT key, value FROM {} WHERE store_name = $1 ORDER BY key ASC LIMIT 1",
301            self.table_ref
302        );
303        // `first_row()` consumes the `AsyncRowset`, releasing its statement
304        // guard on the shared connection BEFORE the DELETE runs — the two
305        // statements never overlap on the connection.
306        let Some(row) = self
307            .connection
308            .query_params(&select, &[&self.store_name.as_str()])
309            .await?
310            .first_row()
311            .await?
312        else {
313            return Ok(None);
314        };
315        let key: String = row
316            .get::<String>(0)
317            .ok_or_else(|| Error::internal("kv pop: key column was unexpectedly NULL"))?;
318        let value: String = row.get::<String>(1).unwrap_or_default();
319        self.connection
320            .command_params(
321                &format!(
322                    "DELETE FROM {} WHERE store_name = $1 AND key = $2",
323                    self.table_ref
324                ),
325                &[&self.store_name.as_str(), &key.as_str()],
326            )
327            .await?;
328        Ok(Some((key, value)))
329    }
330
331    /// Upserts every `(key, value)` pair in one transaction.
332    ///
333    /// All keys are validated before the transaction opens, so an invalid key
334    /// aborts the whole batch without writing anything.
335    ///
336    /// # Errors
337    ///
338    /// See [`KvStore::set_batch`](crate::KvStore::set_batch).
339    pub async fn set_batch(&self, entries: &[(&str, &str)]) -> Result<()> {
340        for (key, _) in entries {
341            validate_kv_name(key, "key")?;
342        }
343        self.connection.begin_transaction_raw().await?;
344        let mut inner: Result<()> = Ok(());
345        for (key, value) in entries {
346            if let Err(e) = self.upsert(key, value).await {
347                inner = Err(e);
348                break;
349            }
350        }
351        match &inner {
352            Ok(()) => self.connection.commit_raw().await?,
353            Err(_) => {
354                let _ = self.connection.rollback_raw().await;
355            }
356        }
357        inner
358    }
359}
360
361impl AsyncConnection {
362    /// Opens a handle to a named KV store, creating the table if needed.
363    ///
364    /// # Errors
365    ///
366    /// See [`Connection::kv_store`](crate::Connection::kv_store).
367    pub async fn kv_store(&self, name: &str) -> Result<AsyncKvStore<'_>> {
368        AsyncKvStore::new(self, name).await
369    }
370
371    /// Async twin of [`Connection::kv_store_in`](crate::Connection::kv_store_in).
372    ///
373    /// # Errors
374    ///
375    /// See [`Connection::kv_store_in`](crate::Connection::kv_store_in).
376    pub async fn kv_store_in(&self, database: &str, name: &str) -> Result<AsyncKvStore<'_>> {
377        AsyncKvStore::with_target(self, name, &kv_target_prefix(database)?).await
378    }
379
380    /// Lists the names of every KV store that currently holds at least one key.
381    ///
382    /// # Errors
383    ///
384    /// See [`Connection::kv_list_stores`](crate::Connection::kv_list_stores).
385    pub async fn kv_list_stores(&self) -> Result<Vec<String>> {
386        self.kv_list_stores_impl(KV_TABLE).await
387    }
388
389    /// Async twin of
390    /// [`Connection::kv_list_stores_in`](crate::Connection::kv_list_stores_in).
391    ///
392    /// # Errors
393    ///
394    /// See [`Connection::kv_list_stores_in`](crate::Connection::kv_list_stores_in).
395    pub async fn kv_list_stores_in(&self, database: &str) -> Result<Vec<String>> {
396        let table_ref = format!("{}.{KV_TABLE}", kv_target_prefix(database)?);
397        self.kv_list_stores_impl(&table_ref).await
398    }
399
400    /// Shared body for [`kv_list_stores`](Self::kv_list_stores) /
401    /// [`kv_list_stores_in`](Self::kv_list_stores_in): the async twin of the sync
402    /// `kv_list_stores_impl`. Factored out so the default and location-aware
403    /// paths cannot drift.
404    async fn kv_list_stores_impl(&self, table_ref: &str) -> Result<Vec<String>> {
405        self.execute_command(&kv_create_table_sql(table_ref))
406            .await?;
407        let mut result = self
408            .execute_query(&format!(
409                "SELECT DISTINCT store_name FROM {table_ref} ORDER BY store_name ASC"
410            ))
411            .await?;
412        let mut names = Vec::new();
413        while let Some(chunk) = result.next_chunk().await? {
414            for row in &chunk {
415                if let Some(name) = row.get::<String>(0) {
416                    names.push(name);
417                }
418            }
419        }
420        Ok(names)
421    }
422}