use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{AppId, ScriptId, ScriptSandbox}; /// Semantic role of a script (v1.1.3). /// /// `Endpoint` scripts have an executable entry point — they bind to HTTP /// routes and act as trigger handlers. `Module` scripts are libraries of /// `fn`/`const` declarations imported by other scripts via Rhai's /// `import "" as ;` syntax. Modules cannot be invoked /// directly: route binding and trigger creation reject `Module` targets. /// /// Serialized as `"endpoint"` / `"module"` so the wire shape is the /// same string the SQL `CHECK (kind IN ('endpoint','module'))` /// constraint enforces. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ScriptKind { #[default] Endpoint, Module, } impl ScriptKind { /// Wire / SQL representation. Inverse of `parse_str`. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Endpoint => "endpoint", Self::Module => "module", } } /// Parse the canonical wire / SQL form. Returns `None` for any /// other input; callers map that to a 400 / `ValidationError`. /// Named `parse_str` (not `from_str`) to dodge the /// `std::str::FromStr` lint without taking on the trait's /// `Result` shape that this caller doesn't need. #[must_use] pub fn parse_str(s: &str) -> Option { match s { "endpoint" => Some(Self::Endpoint), "module" => Some(Self::Module), _ => None, } } } #[cfg(test)] mod kind_tests { use super::*; #[test] fn default_is_endpoint() { assert_eq!(ScriptKind::default(), ScriptKind::Endpoint); } #[test] fn round_trips_through_serde_lowercase() { assert_eq!( serde_json::to_string(&ScriptKind::Endpoint).unwrap(), "\"endpoint\"" ); assert_eq!( serde_json::to_string(&ScriptKind::Module).unwrap(), "\"module\"" ); assert_eq!( serde_json::from_str::("\"endpoint\"").unwrap(), ScriptKind::Endpoint ); assert_eq!( serde_json::from_str::("\"module\"").unwrap(), ScriptKind::Module ); } #[test] fn parse_str_round_trip() { for k in [ScriptKind::Endpoint, ScriptKind::Module] { assert_eq!(ScriptKind::parse_str(k.as_str()), Some(k)); } assert_eq!(ScriptKind::parse_str("invalid"), None); assert_eq!(ScriptKind::parse_str(""), None); } } /// A user-uploaded Rhai script and its execution configuration. /// /// This is the canonical representation that flows between manager (storage), /// orchestrator (dispatch), and executor (run). It must stay serializable /// because in cluster mode it crosses process boundaries on every invocation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Script { pub id: ScriptId, /// Owning app. Set on create, immutable thereafter — a "move to /// another app" is a copy+delete, not an in-place edit (snapshot /// semantics — see blueprint §11.5). pub app_id: AppId, pub name: String, pub description: Option, pub version: i32, pub source: String, /// `Endpoint` (default; the only kind v1.0 through v1.1.2 supported) /// or `Module` (v1.1.3 — imported by other scripts, never bound /// directly to a route or trigger). #[serde(default)] pub kind: ScriptKind, pub timeout_seconds: u32, /// Per-script overrides for Rhai sandbox limits. Empty = platform /// defaults. Values are admin-ceiling-clamped at write time. #[serde(default)] pub sandbox: ScriptSandbox, /// **v1.3+ advisory only.** Real heap limits require OS-level /// isolation (cgroups, process limits) which lands with cluster-mode /// executor sandboxing. The field stays in the schema so we don't /// have to add it back when that's built. pub memory_limit_mb: u32, /// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the /// script is not invocable (route 404s, trigger doesn't fire) but stays /// as desired state (not pruned). Defaults `true`. #[serde(default = "crate::default_true")] pub enabled: bool, pub created_at: DateTime, pub updated_at: DateTime, }