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