Skip to main content

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}