diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index 8ea877b35bfaf5..438adcdf44921a 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -1,3 +1,4 @@ +use anyhow::Context as _; use gpui::App; use sqlez_macros::sql; use util::ResultExt as _; @@ -13,12 +14,22 @@ pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnect impl Domain for KeyValueStore { const NAME: &str = stringify!(KeyValueStore); - const MIGRATIONS: &[&str] = &[sql!( - CREATE TABLE IF NOT EXISTS kv_store( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) STRICT; - )]; + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE IF NOT EXISTS kv_store( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) STRICT; + ), + sql!( + CREATE TABLE IF NOT EXISTS scoped_kv_store( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(namespace, key) + ) STRICT; + ), + ]; } crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []); @@ -69,6 +80,64 @@ impl KeyValueStore { DELETE FROM kv_store WHERE key = (?) } } + + pub fn scoped<'a>(&'a self, namespace: &'a str) -> ScopedKeyValueStore<'a> { + ScopedKeyValueStore { + store: self, + namespace, + } + } +} + +pub struct ScopedKeyValueStore<'a> { + store: &'a KeyValueStore, + namespace: &'a str, +} + +impl ScopedKeyValueStore<'_> { + pub fn read(&self, key: &str) -> anyhow::Result> { + self.store.select_row_bound::<(&str, &str), String>( + "SELECT value FROM scoped_kv_store WHERE namespace = (?) AND key = (?)", + )?((self.namespace, key)) + .context("Failed to read from scoped_kv_store") + } + + pub async fn write(&self, key: String, value: String) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection.exec_bound::<(&str, &str, &str)>( + "INSERT OR REPLACE INTO scoped_kv_store(namespace, key, value) VALUES ((?), (?), (?))", + )?((&namespace, &key, &value)) + .context("Failed to write to scoped_kv_store") + }) + .await + } + + pub async fn delete(&self, key: String) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection.exec_bound::<(&str, &str)>( + "DELETE FROM scoped_kv_store WHERE namespace = (?) AND key = (?)", + )?((&namespace, &key)) + .context("Failed to delete from scoped_kv_store") + }) + .await + } + + pub async fn delete_all(&self) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection + .exec_bound::<&str>("DELETE FROM scoped_kv_store WHERE namespace = (?)")?( + &namespace, + ) + .context("Failed to delete_all from scoped_kv_store") + }) + .await + } } #[cfg(test)] @@ -99,6 +168,52 @@ mod tests { db.delete_kvp("key-1".to_string()).await.unwrap(); assert_eq!(db.read_kvp("key-1").unwrap(), None); } + + #[gpui::test] + async fn test_scoped_kvp() { + let db = KeyValueStore::open_test_db("test_scoped_kvp").await; + + let scope_a = db.scoped("namespace-a"); + let scope_b = db.scoped("namespace-b"); + + // Reading a missing key returns None + assert_eq!(scope_a.read("key-1").unwrap(), None); + + // Writing and reading back a key works + scope_a + .write("key-1".to_string(), "value-a1".to_string()) + .await + .unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string())); + + // Two namespaces with the same key don't collide + scope_b + .write("key-1".to_string(), "value-b1".to_string()) + .await + .unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string())); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + + // delete removes a single key without affecting others in the namespace + scope_a + .write("key-2".to_string(), "value-a2".to_string()) + .await + .unwrap(); + scope_a.delete("key-1".to_string()).await.unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), None); + assert_eq!(scope_a.read("key-2").unwrap(), Some("value-a2".to_string())); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + + // delete_all removes all keys in a namespace without affecting other namespaces + scope_a + .write("key-3".to_string(), "value-a3".to_string()) + .await + .unwrap(); + scope_a.delete_all().await.unwrap(); + assert_eq!(scope_a.read("key-2").unwrap(), None); + assert_eq!(scope_a.read("key-3").unwrap(), None); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + } } pub struct GlobalKeyValueStore(ThreadSafeConnection);