Skip to main content

act_sdk/
sessions.rs

1//! Session lifecycle helpers for components that export
2//! `act:sessions/session-provider`.
3//!
4//! [`SessionRegistry`] is the runtime state container — a map from
5//! component-allocated session-ids to user-defined per-session state
6//! `T`. It is intentionally small; the `#[session_open]` / `#[session_close]`
7//! macros and the macro-generated session-provider exports build on top of it.
8//!
9//! Components are single-threaded under wasm32-wasip2, so this module
10//! uses interior mutability (`RefCell`) without any locking. The
11//! registry is `!Sync` and `!Send` by design — the macro emits a
12//! `thread_local!` static.
13//!
14//! # Manual usage
15//!
16//! ```ignore
17//! use act_sdk::sessions::SessionRegistry;
18//!
19//! pub struct CounterSession { value: u64 }
20//!
21//! thread_local! {
22//!     static SESSIONS: SessionRegistry<CounterSession> =
23//!         SessionRegistry::new("ctr");
24//! }
25//!
26//! // open-session impl:
27//! let id = SESSIONS.with(|r| r.insert(CounterSession { value: 0 }));
28//!
29//! // call-tool impl: read counter for an opaque session-id
30//! let v = SESSIONS.with(|r| r.with(&session_id, |s| s.value));
31//! ```
32//!
33//! # Helpers
34//!
35//! - [`session_id_from_metadata`] — extract `std:session-id` from a
36//!   `metadata` list (CBOR-decoded), per ACT-CONSTANTS.
37
38use std::cell::{Cell, RefCell};
39use std::collections::HashMap;
40
41/// Per-component map of session-id → user state `T`.
42///
43/// `T` is whatever the component wants to keep around for a session
44/// (a database connection, a counter, a parsed config, …). `T` is
45/// stored by value; if it owns expensive resources, drop in
46/// [`SessionRegistry::remove`] runs your `Drop` impl.
47///
48/// Ids are allocated as `"<prefix>_<counter>"` where `prefix` is set
49/// at construction. Components SHOULD use a short, recognisable
50/// prefix; per-component uniqueness is what matters since the host
51/// scopes session-ids to one component instance.
52pub struct SessionRegistry<T> {
53    inner: RefCell<HashMap<String, T>>,
54    next_id: Cell<u64>,
55    prefix: &'static str,
56}
57
58impl<T> SessionRegistry<T> {
59    /// Create an empty registry. `prefix` becomes part of every
60    /// allocated session-id (e.g. `"ctr"` → `"ctr_0"`, `"ctr_1"`, …).
61    pub fn new(prefix: &'static str) -> Self {
62        Self {
63            inner: RefCell::new(HashMap::new()),
64            next_id: Cell::new(0),
65            prefix,
66        }
67    }
68
69    /// Insert a fresh session, returning its allocated id.
70    pub fn insert(&self, value: T) -> String {
71        let n = self.next_id.get();
72        self.next_id.set(n + 1);
73        let id = format!("{}_{}", self.prefix, n);
74        self.inner.borrow_mut().insert(id.clone(), value);
75        id
76    }
77
78    /// Look up a session by id and apply `f` to a shared reference.
79    /// Returns `None` if the id is unknown.
80    pub fn with<R>(&self, id: &str, f: impl FnOnce(&T) -> R) -> Option<R> {
81        self.inner.borrow().get(id).map(f)
82    }
83
84    /// Look up a session by id and apply `f` to a mutable reference.
85    /// Returns `None` if the id is unknown.
86    pub fn with_mut<R>(&self, id: &str, f: impl FnOnce(&mut T) -> R) -> Option<R> {
87        self.inner.borrow_mut().get_mut(id).map(f)
88    }
89
90    /// Remove a session by id. The dropped `T` is returned (if any).
91    pub fn remove(&self, id: &str) -> Option<T> {
92        self.inner.borrow_mut().remove(id)
93    }
94
95    /// Number of currently-open sessions.
96    pub fn len(&self) -> usize {
97        self.inner.borrow().len()
98    }
99
100    /// Whether the registry has no open sessions.
101    pub fn is_empty(&self) -> bool {
102        self.inner.borrow().is_empty()
103    }
104}
105
106/// Extract `std:session-id` from a metadata list (the WIT
107/// `metadata = list<tuple<string, list<u8>>>` shape host calls deliver).
108///
109/// CBOR-decodes the value; returns `None` if the key is absent or
110/// the value isn't a string.
111pub fn session_id_from_metadata(metadata: &[(String, Vec<u8>)]) -> Option<String> {
112    for (key, value) in metadata {
113        if key == act_types::constants::META_SESSION_ID
114            && let Ok(serde_json::Value::String(s)) =
115                ciborium::from_reader::<serde_json::Value, _>(value.as_slice())
116        {
117            return Some(s);
118        }
119    }
120    None
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn ids_are_allocated_in_sequence() {
129        let r = SessionRegistry::<u64>::new("test");
130        assert_eq!(r.insert(0), "test_0");
131        assert_eq!(r.insert(1), "test_1");
132        assert_eq!(r.insert(2), "test_2");
133    }
134
135    #[test]
136    fn lookup_returns_value() {
137        let r = SessionRegistry::<u64>::new("c");
138        let id = r.insert(42);
139        assert_eq!(r.with(&id, |v| *v), Some(42));
140    }
141
142    #[test]
143    fn mutate_updates_value() {
144        let r = SessionRegistry::<u64>::new("c");
145        let id = r.insert(0);
146        r.with_mut(&id, |v| *v += 1);
147        assert_eq!(r.with(&id, |v| *v), Some(1));
148    }
149
150    #[test]
151    fn remove_returns_value_and_clears_entry() {
152        let r = SessionRegistry::<String>::new("c");
153        let id = r.insert("hello".to_string());
154        assert_eq!(r.remove(&id), Some("hello".to_string()));
155        assert_eq!(r.with(&id, |s| s.clone()), None);
156    }
157
158    #[test]
159    fn unknown_id_returns_none() {
160        let r = SessionRegistry::<u64>::new("c");
161        assert!(r.with("c_999", |_| ()).is_none());
162        assert!(r.with_mut("c_999", |_| ()).is_none());
163        assert!(r.remove("c_999").is_none());
164    }
165
166    #[test]
167    fn session_id_extraction_decodes_cbor_string() {
168        let mut buf = Vec::new();
169        ciborium::into_writer(&"abc-123", &mut buf).unwrap();
170        let meta = vec![("std:session-id".to_string(), buf)];
171        assert_eq!(session_id_from_metadata(&meta), Some("abc-123".to_string()));
172    }
173
174    #[test]
175    fn session_id_missing_returns_none() {
176        let meta: Vec<(String, Vec<u8>)> = vec![];
177        assert_eq!(session_id_from_metadata(&meta), None);
178    }
179}