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}