use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{AppId, GroupId, 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, when app-owned. 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). /// /// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**: /// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]). /// A group-owned script (`app_id: None`, `group_id: Some`) is a template /// inherited by every descendant app — it has no single app, so the /// **execution context** app is supplied by the route/trigger/caller that /// invoked it, never read off the script. Reading `app_id` to mean "the /// app this runs under" is therefore a bug for group scripts; use the /// invoking surface's app_id instead. #[serde(default)] pub app_id: Option, /// Owning group, when group-owned (Phase 4). Mutually exclusive with /// `app_id`. The script is resolved by name, nearest-owner-wins, down the /// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of /// the same name shadows the inherited one). #[serde(default)] pub group_id: Option, 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, } /// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with /// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the /// in-memory witness of that invariant so call sites can `match` exhaustively /// instead of juggling two `Option`s. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ScriptOwner { App(AppId), Group(GroupId), } impl Script { /// The script's owner, reconstructed from the polymorphic columns. /// A row that violates the exactly-one invariant (both/neither set — /// impossible under the CHECK) is reported as whichever is present, /// preferring the app, so a corrupt row never panics on the hot path. #[must_use] pub fn owner(&self) -> Option { match (self.app_id, self.group_id) { (Some(a), _) => Some(ScriptOwner::App(a)), (None, Some(g)) => Some(ScriptOwner::Group(g)), (None, None) => None, } } /// True iff this script is owned by `app` directly (not via a group). /// The cheap, app-owned-only ownership check — group inheritance is /// resolved separately (chain-membership), never by this method. #[must_use] pub fn is_owned_by_app(&self, app: AppId) -> bool { self.app_id == Some(app) } }