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}