chronon_core/context.rs
1//! Script execution context ports.
2//!
3//! When a job is scheduled, Chronon stores `actor_json` on the job record. When a worker
4//! dispatches the script, it calls [`ContextFactory::build`] to reconstruct handler context from
5//! that JSON. Script handlers (including those defined with `#[chronon::script]`) receive the
6//! result as `Box<dyn ScriptContext>`.
7//!
8//! # Choosing a factory
9//!
10//! | Approach | When to use |
11//! |----------|-------------|
12//! | [`JsonScriptContextFactory`] | Examples, smoke tests, and handlers that only need [`ScriptContext::label`] and [`ScriptContext::actor_json`] |
13//! | Custom [`ContextFactory`] | Production apps that map actor JSON to sessions, permissions, database access, or other application identity |
14//!
15//! Install the factory at runtime boot on `ChrononBuilder` (see `chronon-runtime`).
16
17use serde_json::Value;
18
19use crate::error::{ChrononError, Result};
20
21/// Opaque execution context for script handlers.
22///
23/// The runtime passes this as the first argument to `#[chronon::script]` handlers and to
24/// registered invoke functions. Use [`Self::label`] for logs and [`Self::actor_json`] when the
25/// handler only needs the captured actor payload from [`crate::models::Job::actor_json`].
26///
27/// # Examples
28///
29/// ```
30/// use chronon_core::{ContextFactory, JsonScriptContextFactory, ScriptContext};
31/// use serde_json::json;
32///
33/// let ctx = JsonScriptContextFactory
34/// .build(&json!({"user": "alice"}))
35/// .unwrap();
36/// assert!(ctx.label().contains("alice"));
37/// assert_eq!(ctx.actor_json()["user"], "alice");
38/// ```
39pub trait ScriptContext: Send {
40 /// Debug label for logs and tests.
41 fn label(&self) -> &str;
42
43 /// Actor JSON captured at schedule time and restored at dispatch.
44 fn actor_json(&self) -> &Value;
45}
46
47/// Builds a [`ScriptContext`] from JSON captured at schedule time.
48///
49/// Install on `ChrononBuilder::context_factory` at boot. The executor calls [`Self::build`] for
50/// every dispatched run using the job's `actor_json`.
51///
52/// | Implementation | When to use |
53/// |----------------|-------------|
54/// | [`JsonScriptContextFactory`] | Examples / handlers that only need label + actor JSON |
55/// | [`NoOpContextFactory`] | Tests and benches |
56/// | Custom | Production identity, sessions, permissions |
57///
58/// # Examples
59///
60/// Custom factory sketch:
61///
62/// ```
63/// use chronon_core::{ContextFactory, Result, ScriptContext};
64/// use serde_json::Value;
65///
66/// struct AppCtx { label: String, actor_json: Value }
67/// impl ScriptContext for AppCtx {
68/// fn label(&self) -> &str { &self.label }
69/// fn actor_json(&self) -> &Value { &self.actor_json }
70/// }
71///
72/// struct AppFactory;
73/// impl ContextFactory for AppFactory {
74/// fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
75/// Ok(Box::new(AppCtx {
76/// label: actor_json.get("user").and_then(|v| v.as_str()).unwrap_or("anon").into(),
77/// actor_json: actor_json.clone(),
78/// }))
79/// }
80/// }
81///
82/// let ctx = AppFactory.build(&serde_json::json!({"user": "bob"})).unwrap();
83/// assert_eq!(ctx.label(), "bob");
84/// ```
85pub trait ContextFactory: Send + Sync {
86 /// Reconstruct handler context from actor JSON stored on the job.
87 ///
88 /// Returns [`IdentityError`] (mapped to [`ChrononError::Internal`]) when the payload
89 /// cannot be decoded into application identity.
90 fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>>;
91}
92
93/// Identity reconstruction failure.
94#[derive(Debug, thiserror::Error)]
95#[error("identity error: {0}")]
96pub struct IdentityError(pub String);
97
98impl From<IdentityError> for ChrononError {
99 fn from(value: IdentityError) -> Self {
100 Self::Internal(value.0)
101 }
102}
103
104/// No-op context for tests and benchmarks.
105#[derive(Debug, Default)]
106pub struct NoOpScriptContext {
107 actor_json: Value,
108}
109
110impl ScriptContext for NoOpScriptContext {
111 fn label(&self) -> &'static str {
112 "noop"
113 }
114
115 fn actor_json(&self) -> &Value {
116 &self.actor_json
117 }
118}
119
120/// Factory that always returns [`NoOpScriptContext`].
121#[derive(Debug, Default, Clone, Copy)]
122pub struct NoOpContextFactory;
123
124impl ContextFactory for NoOpContextFactory {
125 fn build(&self, _actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
126 Ok(Box::new(NoOpScriptContext::default()))
127 }
128}
129
130/// Default factory that wraps actor JSON in a labeled [`ScriptContext`].
131///
132/// Suitable for examples and handlers that only need [`ScriptContext::label`] and
133/// [`ScriptContext::actor_json`]. For application-specific identity (database sessions,
134/// permission checks, typed actors), implement [`ContextFactory`] instead.
135///
136/// # Examples
137///
138/// ```
139/// use chronon_core::{ContextFactory, JsonScriptContextFactory};
140/// use serde_json::json;
141///
142/// let factory = JsonScriptContextFactory;
143/// let ctx = factory.build(&json!({"user": "alice"})).unwrap();
144/// assert!(ctx.label().contains("alice"));
145/// assert_eq!(ctx.actor_json()["user"], "alice");
146/// ```
147#[derive(Debug, Default, Clone, Copy)]
148pub struct JsonScriptContextFactory;
149
150struct JsonContext {
151 actor_json: Value,
152 label: String,
153}
154
155impl ScriptContext for JsonContext {
156 fn label(&self) -> &str {
157 &self.label
158 }
159
160 fn actor_json(&self) -> &Value {
161 &self.actor_json
162 }
163}
164
165impl ContextFactory for JsonScriptContextFactory {
166 fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
167 Ok(Box::new(JsonContext {
168 actor_json: actor_json.clone(),
169 label: actor_json.to_string(),
170 }))
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use serde_json::json;
178
179 struct TestContext {
180 label: String,
181 actor_json: Value,
182 }
183
184 impl ScriptContext for TestContext {
185 fn label(&self) -> &str {
186 &self.label
187 }
188
189 fn actor_json(&self) -> &Value {
190 &self.actor_json
191 }
192 }
193
194 struct TestFactory;
195
196 impl ContextFactory for TestFactory {
197 fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
198 if actor_json.get("System").is_some() {
199 Ok(Box::new(TestContext {
200 label: "system".into(),
201 actor_json: actor_json.clone(),
202 }))
203 } else {
204 Err(IdentityError("missing System".into()).into())
205 }
206 }
207 }
208
209 #[test]
210 fn factory_builds_context() {
211 let factory = TestFactory;
212 let actor = json!({"System": {"operation": "t"}});
213 let ctx = factory.build(&actor).expect("ok");
214 assert_eq!(ctx.label(), "system");
215 assert_eq!(ctx.actor_json(), &actor);
216 }
217
218 #[test]
219 fn json_factory_stores_actor_json() {
220 let factory = JsonScriptContextFactory;
221 let actor = json!({"user": "alice"});
222 let ctx = factory.build(&actor).expect("ok");
223 assert_eq!(ctx.actor_json(), &actor);
224 assert!(ctx.label().contains("alice"));
225 }
226}