Skip to content
22 changes: 22 additions & 0 deletions docs/superpowers/specs/2026-07-08-kv-store-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@
**Date:** 2026-07-08
**Author:** Stefan Steiner (with Claude)

> **Superseded (2026-07-09):** two design points below were settled differently
> during implementation and are retained here only as historical record — the
> authoritative sources are the M1/M2 plans
> (`docs/superpowers/plans/2026-07-08-kv-store-m1-api.md`,
> `docs/superpowers/plans/2026-07-09-kv-store-m2-mcp.md`):
>
> 1. **No PRIMARY KEY.** The PK-enforcement probe found Hyper *rejects* an
> index-backed `PRIMARY KEY` on this table (`0A000`), so the shipped
> `_hyperdb_kv_store` has **no PK/index** at all; `(store_name, key)`
> uniqueness is enforced entirely by the tool layer's UPDATE-then-INSERT
> upsert. Every "composite PK" reference below is stale.
> 2. **M2 PR title uses `feat:`, not `fix:`.** M1 (retitled `chore:`) and M2
> bundle into a single `v0.6.0` minor release, so the MCP milestone ships
> under `feat:`.

## Context

The `hyperdb-api` crate is a pure-Rust client for the Hyper database (PostgreSQL
Expand Down Expand Up @@ -100,6 +115,10 @@ static SQL, no runtime DDL, no `format!`-built table names.

### PRIMARY KEY enforcement — verify empirically

> **Superseded:** the probe found Hyper *rejects* the index-backed `PRIMARY KEY`
> (`0A000`); the shipped table has no PK at all. See the note at the top of this
> doc. The section below is the original, pre-probe reasoning.

The Hyper grammar supports enforced, index-backed `PRIMARY KEY` (default index
is an Adaptive Radix Tree; see `hyper/cts/infra/RelationOptions.hpp`). However, a
comment in `hyperdb-mcp/src/saved_queries.rs` asserts "Hyper has no indexes" and
Expand Down Expand Up @@ -364,6 +383,9 @@ Add a bullet under `## [Unreleased]` in `hyperdb-mcp/CHANGELOG.md`.

## Milestones, branches, PR titles

> **Superseded:** M2 ships under **`feat:`**, not `fix:` — M1 and M2 bundle into a
> single `v0.6.0` minor release. See the note at the top of this doc.

| Milestone | Crate | Branch | PR title prefix |
|---|---|---|---|
| M1 — API | `hyperdb-api` | current branch family | **`feat:`** (the real feature) |
Expand Down
3 changes: 3 additions & 0 deletions hyperdb-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
`KvStore` / `AsyncKvStore` handles over a fixed `_hyperdb_kv_store` table, with
`get`/`set`/`get_as`/`set_as`/`delete`/`exists`/`size`/`keys`/`pop`/`clear`/`set_batch`,
plus `kv_list_stores`. Adds the `Error::Serialization` variant.
- `Connection::kv_store_in(database, name)` / `kv_list_stores_in(database)` (plus the
`AsyncConnection` twins) to open and enumerate KV stores in a specific attached
database. The database name is identifier-escaped internally.

## [0.1.1] - 2026-05-13

Expand Down
47 changes: 37 additions & 10 deletions hyperdb-api/src/async_kv_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use crate::async_connection::AsyncConnection;
use crate::error::{Error, Result};
use crate::kv_store::{kv_create_table_sql, validate_kv_name, KV_TABLE};
use crate::kv_store::{kv_create_table_sql, kv_target_prefix, validate_kv_name, KV_TABLE};

/// A handle to one named key-value store over an [`AsyncConnection`].
///
Expand Down Expand Up @@ -56,13 +56,11 @@ impl<'conn> AsyncKvStore<'conn> {

/// Async twin of [`KvStore::with_target`](crate::KvStore::with_target).
///
/// `target` is interpolated into SQL — the caller must supply a
/// pre-validated / identifier-escaped, SQL-safe qualifier (M2 must escape
/// it before calling).
#[allow(
dead_code,
reason = "M2 (hyperdb-mcp) consumer; kept here so M1 needs no later API change"
)]
/// Crate-internal low-level constructor behind
/// [`AsyncConnection::kv_store_in`](crate::AsyncConnection::kv_store_in).
/// `target` is interpolated into SQL — the caller must supply a pre-escaped,
/// SQL-safe qualifier (public callers go through `kv_store_in`, which
/// escapes for them).
pub(crate) async fn with_target(
connection: &'conn AsyncConnection,
name: &str,
Expand Down Expand Up @@ -370,16 +368,45 @@ impl AsyncConnection {
AsyncKvStore::new(self, name).await
}

/// Async twin of [`Connection::kv_store_in`](crate::Connection::kv_store_in).
///
/// # Errors
///
/// See [`Connection::kv_store_in`](crate::Connection::kv_store_in).
pub async fn kv_store_in(&self, database: &str, name: &str) -> Result<AsyncKvStore<'_>> {
AsyncKvStore::with_target(self, name, &kv_target_prefix(database)?).await
}

/// Lists the names of every KV store that currently holds at least one key.
///
/// # Errors
///
/// See [`Connection::kv_list_stores`](crate::Connection::kv_list_stores).
pub async fn kv_list_stores(&self) -> Result<Vec<String>> {
self.execute_command(&kv_create_table_sql(KV_TABLE)).await?;
self.kv_list_stores_impl(KV_TABLE).await
}

/// Async twin of
/// [`Connection::kv_list_stores_in`](crate::Connection::kv_list_stores_in).
///
/// # Errors
///
/// See [`Connection::kv_list_stores_in`](crate::Connection::kv_list_stores_in).
pub async fn kv_list_stores_in(&self, database: &str) -> Result<Vec<String>> {
let table_ref = format!("{}.{KV_TABLE}", kv_target_prefix(database)?);
self.kv_list_stores_impl(&table_ref).await
}

/// Shared body for [`kv_list_stores`](Self::kv_list_stores) /
/// [`kv_list_stores_in`](Self::kv_list_stores_in): the async twin of the sync
/// `kv_list_stores_impl`. Factored out so the default and location-aware
/// paths cannot drift.
async fn kv_list_stores_impl(&self, table_ref: &str) -> Result<Vec<String>> {
self.execute_command(&kv_create_table_sql(table_ref))
.await?;
let mut result = self
.execute_query(&format!(
"SELECT DISTINCT store_name FROM {KV_TABLE} ORDER BY store_name ASC"
"SELECT DISTINCT store_name FROM {table_ref} ORDER BY store_name ASC"
))
.await?;
let mut names = Vec::new();
Expand Down
95 changes: 82 additions & 13 deletions hyperdb-api/src/kv_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ pub(crate) fn kv_create_table_sql(table_ref: &str) -> String {
)
}

/// Builds the escaped `"<database>"."public"` qualifier prefix for the KV table.
///
/// Single source of truth for the location shape used by
/// [`Connection::kv_store_in`](crate::Connection::kv_store_in) and
/// [`Connection::kv_list_stores_in`](crate::Connection::kv_list_stores_in) (and
/// their async twins): the KV table always lives in the target database's
/// `public` schema. `database` is identifier-escaped via
/// [`escape_name`](crate::escape_name); the fixed `public` schema name is
/// escaped identically for symmetry.
pub(crate) fn kv_target_prefix(database: &str) -> Result<String> {
Ok(format!(
"{}.{}",
crate::escape_name(database)?,
crate::escape_name("public")?
))
}

use crate::connection::Connection;

/// A handle to one named key-value store, backed by `KV_TABLE`.
Expand Down Expand Up @@ -120,17 +137,14 @@ impl<'conn> KvStore<'conn> {
Self::open(connection, name, KV_TABLE.to_string())
}

/// Opens a handle targeting an explicit, already-escaped table reference.
///
/// Crate-internal seam for the MCP milestone (routes into an attached
/// database). `target` is interpolated directly into SQL, so the **caller
/// must supply a pre-validated / identifier-escaped, SQL-safe qualifier**
/// (M2 must escape it via the crate's identifier-quoting before calling —
/// `store_name`/`key`/`value` are always bound params, but `target` is not).
#[allow(
dead_code,
reason = "M2 (hyperdb-mcp) consumer; kept here so M1 needs no later API change"
)]
/// Opens a handle targeting an explicit, already-escaped table-qualifier prefix.
///
/// Crate-internal low-level constructor behind
/// [`Connection::kv_store_in`](crate::Connection::kv_store_in). `target` is
/// interpolated directly into SQL, so the **caller must supply a pre-escaped,
/// SQL-safe qualifier** — public callers go through `kv_store_in`, which
/// escapes for them (`store_name` / `key` / `value` are always bound params,
/// but `target` is not).
pub(crate) fn with_target(
connection: &'conn Connection,
name: &str,
Expand Down Expand Up @@ -438,6 +452,35 @@ impl Connection {
KvStore::new(self, name)
}

/// Opens a handle to a KV store in a specific database, rather than the
/// default (search-path) location.
///
/// `database` is the **unescaped** name of an attached database; the store's
/// backing table is created (if absent) in that database's `public` schema.
/// The name is identifier-escaped internally, so it is safe to pass an
/// arbitrary attachment alias. The store name, keys, and values are always
/// bound parameters.
///
/// # Examples
///
/// ```no_run
/// # use hyperdb_api::{Connection, CreateMode, Result};
/// # fn example(conn: &Connection) -> Result<()> {
/// let kv = conn.kv_store_in("persistent", "settings")?;
/// kv.set("theme", "dark")?;
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// - [`Error::InvalidName`] if `database` or `name` is invalid.
/// - [`Error::FeatureNotSupported`] on gRPC transport.
/// - [`Error::Server`] if creating the backing table fails.
pub fn kv_store_in(&self, database: &str, name: &str) -> Result<KvStore<'_>> {
KvStore::with_target(self, name, &kv_target_prefix(database)?)
}

/// Lists the names of every KV store that currently holds at least one key.
///
/// Creates the backing table first (via `kv_create_table_sql`) so calling
Expand All @@ -449,9 +492,35 @@ impl Connection {
/// - [`Error::FeatureNotSupported`] on gRPC transport.
/// - [`Error::Server`] if the query fails.
pub fn kv_list_stores(&self) -> Result<Vec<String>> {
self.execute_command(&kv_create_table_sql(KV_TABLE))?;
self.kv_list_stores_impl(KV_TABLE)
}

/// Lists the KV stores that hold at least one key in a specific database.
///
/// The location-aware companion to [`kv_list_stores`](Self::kv_list_stores):
/// `database` is the unescaped name of an attached database (escaped
/// internally). Creates the backing table in that database's `public` schema
/// first, so an empty database returns `[]` rather than erroring.
///
/// # Errors
///
/// - [`Error::InvalidName`] if `database` is invalid.
/// - [`Error::FeatureNotSupported`] on gRPC transport.
/// - [`Error::Server`] if the query fails.
pub fn kv_list_stores_in(&self, database: &str) -> Result<Vec<String>> {
let table_ref = format!("{}.{KV_TABLE}", kv_target_prefix(database)?);
self.kv_list_stores_impl(&table_ref)
}

/// Shared body for [`kv_list_stores`](Self::kv_list_stores) /
/// [`kv_list_stores_in`](Self::kv_list_stores_in): create the backing table
/// at `table_ref` (so a fresh location returns `[]`), then list the distinct
/// store names there. Factored out so the default and location-aware paths
/// cannot drift.
fn kv_list_stores_impl(&self, table_ref: &str) -> Result<Vec<String>> {
self.execute_command(&kv_create_table_sql(table_ref))?;
let mut result = self.execute_query(&format!(
"SELECT DISTINCT store_name FROM {KV_TABLE} ORDER BY store_name ASC"
"SELECT DISTINCT store_name FROM {table_ref} ORDER BY store_name ASC"
))?;
let mut names = Vec::new();
while let Some(chunk) = result.next_chunk()? {
Expand Down
114 changes: 114 additions & 0 deletions hyperdb-api/tests/kv_store_in_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Integration tests for `Connection::kv_store_in` / `kv_list_stores_in`
//! (targeted KV location), sync and async.

mod common;

use common::{test_hyper_params, test_result_path};
use hyperdb_api::{escape_sql_path, AsyncConnection, Connection, CreateMode, HyperProcess, Result};

/// A KV store opened in an attached database is isolated from the primary DB,
/// round-trips set/get through the attached location, and is enumerated by the
/// location-aware store listing.
#[test]
fn kv_store_in_targets_attached_database() -> Result<()> {
let params = test_hyper_params("kv_store_in_sync")?;
let hyper = HyperProcess::new(None, Some(&params))?;

// Primary (ephemeral-style) DB.
let primary = test_result_path("kv_store_in_sync_primary", "hyper")?;
let conn = Connection::new(&hyper, &primary, CreateMode::CreateAndReplace)?;
let primary_stem = primary
.file_stem()
.and_then(|s| s.to_str())
.expect("primary stem");

// Attach a second DB under alias "aux".
let aux_path = test_result_path("kv_store_in_sync_aux", "hyper")?;
let _ = std::fs::remove_file(&aux_path);
let aux_str = aux_path.to_string_lossy();
conn.execute_command(&format!(
"CREATE DATABASE IF NOT EXISTS {}",
escape_sql_path(&aux_str)
))?;
conn.execute_command(&format!(
"ATTACH DATABASE {} AS \"aux\"",
escape_sql_path(&aux_str)
))?;
// ATTACH shifts schema_search_path; re-pin to the primary so the *default*
// (unqualified) KV location keeps resolving to the primary DB. Mirrors
// `hyperdb-mcp`'s engine, which pins search_path at attach time.
conn.execute_command(&format!(
"SET schema_search_path = '{}'",
primary_stem.replace('\'', "''")
))?;

// Open a KV store in the attached DB — pass the BARE alias; escaping is internal.
let kv = conn.kv_store_in("aux", "settings")?;
kv.set("theme", "dark")?;
assert_eq!(kv.get("theme")?, Some("dark".to_string()));

// The default-location store must NOT see the attached-DB value.
let default_kv = conn.kv_store("settings")?;
assert_eq!(default_kv.get("theme")?, None);

// The location-aware listing sees the attached-DB store; the primary has none.
assert_eq!(conn.kv_list_stores_in("aux")?, vec!["settings".to_string()]);
assert!(conn.kv_list_stores()?.is_empty());

Ok(())
}

/// Async twin of the above — proves the async `kv_store_in` / `kv_list_stores_in`
/// route to the attached DB and stay isolated from the primary.
#[tokio::test(flavor = "current_thread")]
async fn async_kv_store_in_targets_attached_database() -> Result<()> {
let params = test_hyper_params("kv_store_in_async")?;
let hyper = HyperProcess::new(None, Some(&params))?;
let endpoint = hyper.require_endpoint()?.to_string();

let primary = test_result_path("kv_store_in_async_primary", "hyper")?;
let conn = AsyncConnection::connect(
&endpoint,
primary.to_str().expect("primary path"),
CreateMode::CreateAndReplace,
)
.await?;
let primary_stem = primary
.file_stem()
.and_then(|s| s.to_str())
.expect("primary stem");

let aux_path = test_result_path("kv_store_in_async_aux", "hyper")?;
let _ = std::fs::remove_file(&aux_path);
let aux_str = aux_path.to_string_lossy();
conn.execute_command(&format!(
"CREATE DATABASE IF NOT EXISTS {}",
escape_sql_path(&aux_str)
))
.await?;
conn.execute_command(&format!(
"ATTACH DATABASE {} AS \"aux\"",
escape_sql_path(&aux_str)
))
.await?;
conn.execute_command(&format!(
"SET schema_search_path = '{}'",
primary_stem.replace('\'', "''")
))
.await?;

let kv = conn.kv_store_in("aux", "settings").await?;
kv.set("theme", "dark").await?;
assert_eq!(kv.get("theme").await?, Some("dark".to_string()));
assert_eq!(conn.kv_store("settings").await?.get("theme").await?, None);
assert_eq!(
conn.kv_list_stores_in("aux").await?,
vec!["settings".to_string()]
);
assert!(conn.kv_list_stores().await?.is_empty());

Ok(())
}
18 changes: 18 additions & 0 deletions hyperdb-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- **Key-value scratchpad tools** — eight new MCP tools (`kv_set`, `kv_get`,
`kv_delete`, `kv_list`, `kv_list_stores`, `kv_size`, `kv_pop`, `kv_clear`)
let an LLM stash variables, state, summaries, or JSON strings under a
`store`/`key` namespace without creating a table. Each takes the same
optional `database`/`persist` routing as the data tools; **stores are
ephemeral by default** (lost on server restart) and persist only when
routed to `"persistent"` (or `persist: true`) or an attached alias. Each
database keeps its own isolated set of stores. The mutating tools
(`kv_set`, `kv_delete`, `kv_pop`, `kv_clear`) are disabled in read-only
mode; the readers (`kv_get`, `kv_list`, `kv_size`, `kv_list_stores`)
always work.
- **`hyper://schema/kv` resource** describing the `_hyperdb_kv_store`
backing table, its indexless shape, the ephemeral-vs-persistent
durability rule, per-database isolation, and the `LEFT JOIN` pattern for
enriching analytical tables with KV metadata.

### Fixed

- **TCP keepalive on the `hyperd` connection.** Connections to `hyperd` now
Expand Down
Loading
Loading