adk_session/service.rs
1use crate::{Event, Session};
2use adk_core::identity::{AdkIdentity, AppName, SessionId, UserId};
3use adk_core::{AdkError, ErrorComponent, Result};
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use serde_json::Value;
7use std::collections::HashMap;
8
9/// Request to create a new session.
10#[derive(Debug, Clone)]
11pub struct CreateRequest {
12 /// Application name that owns the session.
13 pub app_name: String,
14 /// User identifier for the session owner.
15 pub user_id: String,
16 /// Optional session ID; generated if not provided.
17 pub session_id: Option<String>,
18 /// Initial state key-value pairs for the session.
19 pub state: HashMap<String, Value>,
20}
21
22impl CreateRequest {
23 /// Returns the application name as a typed [`AppName`].
24 ///
25 /// # Errors
26 ///
27 /// Returns an error if the raw string fails identity validation.
28 pub fn try_app_name(&self) -> Result<AppName> {
29 Ok(AppName::try_from(self.app_name.as_str())?)
30 }
31
32 /// Returns the user identifier as a typed [`UserId`].
33 ///
34 /// # Errors
35 ///
36 /// Returns an error if the raw string fails identity validation.
37 pub fn try_user_id(&self) -> Result<UserId> {
38 Ok(UserId::try_from(self.user_id.as_str())?)
39 }
40
41 /// Returns the session identifier as a typed [`SessionId`], if one was
42 /// provided.
43 ///
44 /// Returns `Ok(None)` when `session_id` is `None` (the service will
45 /// generate one). Returns an error only when a non-`None` value fails
46 /// identity validation.
47 ///
48 /// # Errors
49 ///
50 /// Returns an error if the provided session ID string fails validation.
51 pub fn try_session_id(&self) -> Result<Option<SessionId>> {
52 self.session_id.as_deref().map(SessionId::try_from).transpose().map_err(Into::into)
53 }
54
55 /// Returns the stable session-scoped [`AdkIdentity`] triple, if a session
56 /// ID was provided.
57 ///
58 /// Because `CreateRequest` allows `session_id` to be `None` (the backend
59 /// generates one), this returns `Ok(None)` when no session ID is present.
60 ///
61 /// # Errors
62 ///
63 /// Returns an error if any of the constituent identifiers fail validation.
64 pub fn try_identity(&self) -> Result<Option<AdkIdentity>> {
65 let Some(sid) = self.try_session_id()? else {
66 return Ok(None);
67 };
68 Ok(Some(AdkIdentity {
69 app_name: self.try_app_name()?,
70 user_id: self.try_user_id()?,
71 session_id: sid,
72 }))
73 }
74}
75
76/// Request to retrieve an existing session.
77#[derive(Debug, Clone)]
78pub struct GetRequest {
79 /// Application name that owns the session.
80 pub app_name: String,
81 /// User identifier for the session owner.
82 pub user_id: String,
83 /// Session identifier to retrieve.
84 pub session_id: String,
85 /// If set, only return the N most recent events.
86 pub num_recent_events: Option<usize>,
87 /// If set, only return events after this timestamp.
88 pub after: Option<DateTime<Utc>>,
89}
90
91impl GetRequest {
92 /// Returns the stable session-scoped [`AdkIdentity`] triple.
93 ///
94 /// Parses `app_name`, `user_id`, and `session_id` into their typed
95 /// equivalents and combines them into an [`AdkIdentity`].
96 ///
97 /// # Errors
98 ///
99 /// Returns an error if any of the three identifiers fail validation.
100 pub fn try_identity(&self) -> Result<AdkIdentity> {
101 Ok(AdkIdentity {
102 app_name: AppName::try_from(self.app_name.as_str())?,
103 user_id: UserId::try_from(self.user_id.as_str())?,
104 session_id: SessionId::try_from(self.session_id.as_str())?,
105 })
106 }
107}
108
109/// Builds the canonical error returned when a session identity has no matching record.
110pub(crate) fn session_not_found(req: &GetRequest) -> AdkError {
111 AdkError::not_found(
112 ErrorComponent::Session,
113 "session.not_found",
114 format!(
115 "session '{}' was not found for app '{}' and user '{}'",
116 req.session_id, req.app_name, req.user_id
117 ),
118 )
119}
120
121/// Request to list sessions for a given app and user.
122#[derive(Debug, Clone)]
123pub struct ListRequest {
124 /// Application name to filter sessions by.
125 pub app_name: String,
126 /// User identifier to filter sessions by.
127 pub user_id: String,
128 /// Maximum number of sessions to return. `None` means no limit.
129 pub limit: Option<usize>,
130 /// Number of sessions to skip for pagination. `None` means start from the beginning.
131 pub offset: Option<usize>,
132}
133
134impl ListRequest {
135 /// Returns the application name as a typed [`AppName`].
136 ///
137 /// # Errors
138 ///
139 /// Returns an error if the raw string fails identity validation.
140 pub fn try_app_name(&self) -> Result<AppName> {
141 Ok(AppName::try_from(self.app_name.as_str())?)
142 }
143
144 /// Returns the user identifier as a typed [`UserId`].
145 ///
146 /// # Errors
147 ///
148 /// Returns an error if the raw string fails identity validation.
149 pub fn try_user_id(&self) -> Result<UserId> {
150 Ok(UserId::try_from(self.user_id.as_str())?)
151 }
152}
153
154/// Request to append an event to a session using typed [`AdkIdentity`] addressing.
155///
156/// This is the preferred way to append events in new code because it uses the
157/// full `(app_name, user_id, session_id)` triple, eliminating ambiguity that
158/// can arise when a bare `session_id` string is not globally unique.
159///
160/// # Example
161///
162/// ```rust
163/// use adk_core::identity::{AdkIdentity, AppName, SessionId, UserId};
164/// use adk_session::AppendEventRequest;
165/// use adk_session::Event;
166///
167/// let identity = AdkIdentity::new(
168/// AppName::try_from("weather-app").unwrap(),
169/// UserId::try_from("user-123").unwrap(),
170/// SessionId::try_from("session-456").unwrap(),
171/// );
172///
173/// let event = Event::new("inv-001");
174/// let req = AppendEventRequest { identity, event };
175/// ```
176#[derive(Debug, Clone)]
177pub struct AppendEventRequest {
178 /// The typed session-scoped identity triple.
179 pub identity: AdkIdentity,
180 /// The event to append.
181 pub event: Event,
182}
183
184/// Request to delete a session.
185#[derive(Debug, Clone)]
186pub struct DeleteRequest {
187 /// Application name that owns the session.
188 pub app_name: String,
189 /// User identifier for the session owner.
190 pub user_id: String,
191 /// Session identifier to delete.
192 pub session_id: String,
193}
194
195impl DeleteRequest {
196 /// Returns the stable session-scoped [`AdkIdentity`] triple.
197 ///
198 /// Parses `app_name`, `user_id`, and `session_id` into their typed
199 /// equivalents and combines them into an [`AdkIdentity`].
200 ///
201 /// # Errors
202 ///
203 /// Returns an error if any of the three identifiers fail validation.
204 pub fn try_identity(&self) -> Result<AdkIdentity> {
205 Ok(AdkIdentity {
206 app_name: AppName::try_from(self.app_name.as_str())?,
207 user_id: UserId::try_from(self.user_id.as_str())?,
208 session_id: SessionId::try_from(self.session_id.as_str())?,
209 })
210 }
211}
212
213/// Trait for session persistence backends.
214///
215/// Implementations manage the full lifecycle of sessions: creation, retrieval,
216/// listing, deletion, and event appending.
217#[async_trait]
218pub trait SessionService: Send + Sync {
219 /// Create a new session and return it.
220 async fn create(&self, req: CreateRequest) -> Result<Box<dyn Session>>;
221 /// Retrieves an existing session by its complete identity.
222 ///
223 /// Implementations address sessions by the full `(app_name, user_id, session_id)` tuple.
224 /// When that valid identity has no matching record, implementations return an
225 /// [`AdkError`] with code `session.not_found` and a
226 /// [`NotFound`](adk_core::ErrorCategory::NotFound) category. Backend and transport failures
227 /// must not be reported as not-found errors.
228 ///
229 /// # Errors
230 ///
231 /// Returns an invalid-input error when an identifier fails validation, `session.not_found`
232 /// when no matching session exists, or a backend-specific error when retrieval fails.
233 async fn get(&self, req: GetRequest) -> Result<Box<dyn Session>>;
234 /// List sessions for a given app and user.
235 async fn list(&self, req: ListRequest) -> Result<Vec<Box<dyn Session>>>;
236 /// Delete a session by its identifiers.
237 async fn delete(&self, req: DeleteRequest) -> Result<()>;
238 /// Append an event to a session identified by its session ID string.
239 async fn append_event(&self, session_id: &str, event: Event) -> Result<()>;
240
241 /// Get a session using typed [`AdkIdentity`] addressing.
242 ///
243 /// This is the preferred path for new code. It constructs a [`GetRequest`]
244 /// from the full `(app_name, user_id, session_id)` triple so that session
245 /// lookup is unambiguous.
246 ///
247 /// The default implementation delegates to
248 /// [`get`](SessionService::get) with a freshly built [`GetRequest`].
249 ///
250 /// # Errors
251 ///
252 /// Returns an error if the session cannot be retrieved.
253 async fn get_for_identity(&self, identity: &AdkIdentity) -> Result<Box<dyn Session>> {
254 self.get(GetRequest {
255 app_name: identity.app_name.as_ref().to_string(),
256 user_id: identity.user_id.as_ref().to_string(),
257 session_id: identity.session_id.as_ref().to_string(),
258 num_recent_events: None,
259 after: None,
260 })
261 .await
262 }
263
264 /// Delete a session using typed [`AdkIdentity`] addressing.
265 ///
266 /// This is the preferred path for new code. It constructs a
267 /// [`DeleteRequest`] from the full `(app_name, user_id, session_id)` triple
268 /// so that session deletion is unambiguous.
269 ///
270 /// The default implementation delegates to
271 /// [`delete`](SessionService::delete) with a freshly built
272 /// [`DeleteRequest`].
273 ///
274 /// # Errors
275 ///
276 /// Returns an error if the session cannot be deleted.
277 async fn delete_for_identity(&self, identity: &AdkIdentity) -> Result<()> {
278 self.delete(DeleteRequest {
279 app_name: identity.app_name.as_ref().to_string(),
280 user_id: identity.user_id.as_ref().to_string(),
281 session_id: identity.session_id.as_ref().to_string(),
282 })
283 .await
284 }
285
286 /// Append an event to a session using typed [`AdkIdentity`] addressing.
287 ///
288 /// This is the preferred path for new code. It uses the full
289 /// `(app_name, user_id, session_id)` triple so that session lookup is
290 /// unambiguous even when the same `session_id` string appears under
291 /// different apps or users.
292 ///
293 /// The default implementation delegates to the legacy
294 /// [`append_event`](SessionService::append_event) method using only the
295 /// `session_id` component. Backends that support composite-key addressing
296 /// should override this method to use all three identity fields.
297 ///
298 /// # Errors
299 ///
300 /// Returns an error if the event cannot be appended.
301 async fn append_event_for_identity(&self, req: AppendEventRequest) -> Result<()> {
302 self.append_event(req.identity.session_id.as_ref(), req.event).await
303 }
304
305 /// Delete all sessions for a given app and user.
306 ///
307 /// Removes all sessions and their associated events. Useful for
308 /// bulk cleanup and GDPR right-to-erasure compliance.
309 /// The default implementation returns an error.
310 async fn delete_all_sessions(&self, app_name: &str, user_id: &str) -> Result<()> {
311 let _ = (app_name, user_id);
312 Err(adk_core::AdkError::session("delete_all_sessions not implemented"))
313 }
314
315 /// Rewind a session to the specified event, removing all subsequent events
316 /// and rebuilding state from remaining events' state deltas.
317 ///
318 /// After rewinding, the session will contain only events up to and including
319 /// the target event, and the session state will reflect the cumulative
320 /// application of those events' state deltas.
321 ///
322 /// # Errors
323 ///
324 /// Returns an error if the backend does not support rewind, the session is
325 /// not found, or the target event ID does not exist in the session.
326 async fn rewind(&self, _session_id: &str, _target_event_id: &str) -> Result<Box<dyn Session>> {
327 Err(adk_core::AdkError::session("rewind not supported by this backend"))
328 }
329
330 /// Rewind a session by N steps from the end.
331 ///
332 /// If `steps` is 0, returns the session unchanged. If `steps` exceeds the
333 /// number of events, returns an error.
334 ///
335 /// # Errors
336 ///
337 /// Returns an error if the backend does not support rewind, the session is
338 /// not found, or `steps` exceeds the event count.
339 async fn rewind_steps(&self, _session_id: &str, _steps: usize) -> Result<Box<dyn Session>> {
340 Err(adk_core::AdkError::session("rewind_steps not supported by this backend"))
341 }
342
343 /// Verify backend connectivity.
344 ///
345 /// Returns `Ok(())` if the backend is reachable and responsive.
346 /// Use this for Kubernetes readiness probes and `/healthz` endpoints.
347 /// The default implementation always succeeds (suitable for in-memory).
348 async fn health_check(&self) -> Result<()> {
349 Ok(())
350 }
351}