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}