Skip to main content

agentmux_srv/backend/storage/
widget_storage.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! A widget package's own key-value store, the `storage` permission
5//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md ยง6.3): per
6//! package, not per pane, values as JSON text. The limits are the caller's
7//! (`backend/widget_access.rs`); this module only counts bytes.
8
9use rusqlite::{params, OptionalExtension};
10
11use super::error::StoreError;
12use super::store::Store;
13
14impl Store {
15    pub fn widget_storage_get(&self, widget_id: &str, key: &str) -> Result<Option<String>, StoreError> {
16        let conn = self.conn.lock().unwrap();
17        Ok(conn
18            .query_row(
19                "SELECT value FROM db_widget_storage WHERE widget_id = ?1 AND key = ?2",
20                params![widget_id, key],
21                |row| row.get(0),
22            )
23            .optional()?)
24    }
25
26    /// Sets `key`, unless the package's keys and values would then take more
27    /// than `max_total` bytes; returns whether it was set.
28    pub fn widget_storage_set(&self, widget_id: &str, key: &str, value: &str, max_total: usize) -> Result<bool, StoreError> {
29        let mut conn = self.conn.lock().unwrap();
30        let tx = conn.transaction()?;
31        let others: i64 = tx.query_row(
32            "SELECT COALESCE(SUM(length(CAST(key AS BLOB)) + length(CAST(value AS BLOB))), 0)
33             FROM db_widget_storage WHERE widget_id = ?1 AND key <> ?2",
34            params![widget_id, key],
35            |row| row.get(0),
36        )?;
37        if others as usize + key.len() + value.len() > max_total {
38            return Ok(false);
39        }
40        tx.execute(
41            "INSERT OR REPLACE INTO db_widget_storage (widget_id, key, value) VALUES (?1, ?2, ?3)",
42            params![widget_id, key, value],
43        )?;
44        tx.commit()?;
45        Ok(true)
46    }
47
48    pub fn widget_storage_delete(&self, widget_id: &str, key: &str) -> Result<(), StoreError> {
49        let conn = self.conn.lock().unwrap();
50        conn.execute("DELETE FROM db_widget_storage WHERE widget_id = ?1 AND key = ?2", params![widget_id, key])?;
51        Ok(())
52    }
53
54    /// The package's keys that start with `prefix`, sorted.
55    pub fn widget_storage_keys(&self, widget_id: &str, prefix: &str) -> Result<Vec<String>, StoreError> {
56        let conn = self.conn.lock().unwrap();
57        let mut stmt = conn.prepare(
58            "SELECT key FROM db_widget_storage WHERE widget_id = ?1 AND substr(key, 1, length(?2)) = ?2 ORDER BY key",
59        )?;
60        let keys = stmt.query_map(params![widget_id, prefix], |row| row.get(0))?.collect::<Result<Vec<String>, _>>()?;
61        Ok(keys)
62    }
63
64    /// Everything the package stored (when it's uninstalled).
65    pub fn widget_storage_purge(&self, widget_id: &str) -> Result<(), StoreError> {
66        let conn = self.conn.lock().unwrap();
67        conn.execute("DELETE FROM db_widget_storage WHERE widget_id = ?1", params![widget_id])?;
68        Ok(())
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use std::path::Path;
75
76    use super::*;
77
78    #[test]
79    fn keeps_each_package_apart_within_its_budget() {
80        let store = Store::open(Path::new(":memory:")).unwrap();
81        assert!(store.widget_storage_set("acme.a", "note:1", "\"hi\"", 100).unwrap());
82        assert!(store.widget_storage_set("acme.a", "note:2", "\"yo\"", 100).unwrap());
83        assert!(store.widget_storage_set("acme.b", "note:1", "\"other\"", 100).unwrap());
84        assert_eq!(store.widget_storage_get("acme.a", "note:1").unwrap().as_deref(), Some("\"hi\""));
85        assert_eq!(store.widget_storage_keys("acme.a", "note:").unwrap(), vec!["note:1", "note:2"]);
86        assert!(store.widget_storage_keys("acme.a", "x").unwrap().is_empty());
87        // Over the budget: refused, and nothing changes.
88        assert!(!store.widget_storage_set("acme.a", "big", &"x".repeat(90), 100).unwrap());
89        assert_eq!(store.widget_storage_get("acme.a", "big").unwrap(), None);
90        // Replacing a value counts the new size, not both.
91        assert!(store.widget_storage_set("acme.a", "note:1", &"y".repeat(70), 100).unwrap());
92        store.widget_storage_delete("acme.a", "note:2").unwrap();
93        assert_eq!(store.widget_storage_keys("acme.a", "").unwrap(), vec!["note:1"]);
94        store.widget_storage_purge("acme.a").unwrap();
95        assert!(store.widget_storage_keys("acme.a", "").unwrap().is_empty());
96        assert_eq!(store.widget_storage_get("acme.b", "note:1").unwrap().as_deref(), Some("\"other\""));
97    }
98}