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}