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