use std::{ fmt::{self, Display}, hash::{Hash, Hasher}, ops::Deref, str::FromStr, }; use itertools::Itertools; use serde::de::Error as _; use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serialize}; use std::collections::HashMap; use crate::{ assets::AssetWithAltAccessType, runnable_settings::{ConcurrencySettings, DebouncingSettings}, }; #[derive(Serialize, Deserialize, Debug, Clone, Hash)] pub struct ScriptModule { pub content: String, pub language: ScriptLang, #[serde(skip_serializing_if = "Option::is_none")] pub lock: Option, } #[derive( Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Hash, Eq, sqlx::Type, Default, Ord, PartialOrd, )] #[sqlx(type_name = "SCRIPT_LANG", rename_all = "lowercase")] #[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] pub enum ScriptLang { Nativets, #[default] Deno, Python3, Go, Bash, Powershell, Postgresql, Bun, Bunnative, Mysql, Bigquery, Snowflake, Graphql, Mssql, OracleDB, DuckDb, Php, Rust, Ansible, CSharp, Nu, Java, Ruby, Rlang, Dbt, // for related places search: ADD_NEW_LANG } impl ScriptLang { pub fn as_str(&self) -> &'static str { match self { ScriptLang::Bun => "bun", ScriptLang::Bunnative => "bunnative", ScriptLang::Nativets => "nativets", ScriptLang::Deno => "deno", ScriptLang::Python3 => "python3", ScriptLang::Go => "go", ScriptLang::Bash => "bash", ScriptLang::Powershell => "powershell", ScriptLang::Postgresql => "postgresql", ScriptLang::Mysql => "mysql", ScriptLang::Bigquery => "bigquery", ScriptLang::Snowflake => "snowflake", ScriptLang::Mssql => "mssql", ScriptLang::Graphql => "graphql", ScriptLang::OracleDB => "oracledb", ScriptLang::DuckDb => "duckdb", ScriptLang::Php => "php", ScriptLang::Rust => "rust", ScriptLang::Ansible => "ansible", ScriptLang::CSharp => "csharp", ScriptLang::Nu => "nu", ScriptLang::Java => "java", ScriptLang::Ruby => "ruby", ScriptLang::Rlang => "rlang", ScriptLang::Dbt => "dbt", // for related places search: ADD_NEW_LANG } } pub fn as_dependencies_filename(&self) -> Option { use ScriptLang::*; Some( match self { Bun | Bunnative | Nativets => "package.json", Python3 => "requirements.in", // Go => "go.mod", Php => "composer.json", Powershell => "modules.json", _ => return None, } .to_owned(), ) } pub fn is_native(&self) -> bool { matches!( self, ScriptLang::Bunnative | ScriptLang::Nativets | ScriptLang::Postgresql | ScriptLang::Mysql | ScriptLang::Graphql | ScriptLang::Snowflake | ScriptLang::Mssql | ScriptLang::Bigquery | ScriptLang::OracleDB ) } pub fn as_comment_lit(&self) -> String { use ScriptLang::*; match self { Nativets | Bun | Bunnative | Deno | Go | Php | CSharp | Java => "//", Python3 | Bash | Powershell | Graphql | Ansible | Nu | Ruby | Rlang | Dbt => "#", Postgresql | Mysql | Bigquery | Snowflake | Mssql | OracleDB | DuckDb => "--", Rust => "//!", // for related places search: ADD_NEW_LANG } .to_owned() } } impl FromStr for ScriptLang { type Err = anyhow::Error; fn from_str(s: &str) -> Result { let language = match s.to_lowercase().as_str() { "bun" => ScriptLang::Bun, "bunnative" => ScriptLang::Bunnative, "nativets" => ScriptLang::Nativets, "deno" => ScriptLang::Deno, "python3" => ScriptLang::Python3, "go" => ScriptLang::Go, "bash" => ScriptLang::Bash, "powershell" => ScriptLang::Powershell, "postgresql" => ScriptLang::Postgresql, "mysql" => ScriptLang::Mysql, "bigquery" => ScriptLang::Bigquery, "snowflake" => ScriptLang::Snowflake, "mssql" => ScriptLang::Mssql, "graphql" => ScriptLang::Graphql, "oracledb" => ScriptLang::OracleDB, "php" => ScriptLang::Php, "rust" => ScriptLang::Rust, "ansible" => ScriptLang::Ansible, "csharp" => ScriptLang::CSharp, "nu" => ScriptLang::Nu, "java" => ScriptLang::Java, "ruby" => ScriptLang::Ruby, "rlang" => ScriptLang::Rlang, "dbt" => ScriptLang::Dbt, // for related places search: ADD_NEW_LANG language => return Err(anyhow::anyhow!("{} is currently not supported", language)), }; Ok(language) } } #[derive(Eq, PartialEq, Debug, Hash, Clone, Copy, sqlx::Type)] #[sqlx(transparent)] pub struct ScriptHash(pub i64); impl Deref for ScriptHash { type Target = i64; fn deref(&self) -> &Self::Target { &self.0 } } impl Into for ScriptHash { fn into(self) -> u64 { self.0 as u64 } } impl From for ScriptHash { fn from(value: i64) -> Self { Self(value) } } #[derive(PartialEq, sqlx::Type, Debug)] #[sqlx(transparent, no_pg_array)] pub struct ScriptHashes(pub Vec); impl Display for ScriptHash { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", to_hex_string(&self.0)) } } impl Serialize for ScriptHash { fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { serializer.serialize_str(to_hex_string(&self.0).as_str()) } } impl<'de> Deserialize<'de> for ScriptHash { fn deserialize(deserializer: D) -> std::result::Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; let i = to_i64(&s).map_err(|e| { tracing::error!("Could not deserialize ScriptHash. Note, input should be in Hex and digit amount should be divisible by 16 (can be padded). err: {}", &e); D::Error::custom(format!("{}", e)) })?; Ok(ScriptHash(i)) } } impl Serialize for ScriptHashes { fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { let mut seq = serializer.serialize_seq(Some(self.0.len()))?; for element in &self.0 { seq.serialize_element(&ScriptHash(*element))?; } seq.end() } } #[derive(Serialize, Deserialize, Debug, Hash, sqlx::Type)] #[sqlx(type_name = "SCRIPT_KIND", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] pub enum ScriptKind { Trigger, Failure, Script, Approval, Preprocessor, } impl Display for ScriptKind { fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { fmt.write_str(match self { ScriptKind::Trigger => "trigger", ScriptKind::Failure => "failure", ScriptKind::Script => "script", ScriptKind::Approval => "approval", ScriptKind::Preprocessor => "preprocessor", })?; Ok(()) } } const PREVIEW_IS_CODEBASE_HASH: i64 = -42; const PREVIEW_IS_TAR_CODEBASE_HASH: i64 = -43; const PREVIEW_IS_ESM_CODEBASE_HASH: i64 = -44; const PREVIEW_IS_TAR_ESM_CODEBASE_HASH: i64 = -45; pub fn is_special_codebase_hash(hash: i64) -> bool { hash == PREVIEW_IS_CODEBASE_HASH || hash == PREVIEW_IS_TAR_CODEBASE_HASH || hash == PREVIEW_IS_ESM_CODEBASE_HASH || hash == PREVIEW_IS_TAR_ESM_CODEBASE_HASH } pub fn codebase_to_hash(is_tar: bool, is_esm: bool) -> i64 { if is_tar { if is_esm { PREVIEW_IS_TAR_ESM_CODEBASE_HASH } else { PREVIEW_IS_TAR_CODEBASE_HASH } } else { if is_esm { PREVIEW_IS_ESM_CODEBASE_HASH } else { PREVIEW_IS_CODEBASE_HASH } } } pub fn hash_to_codebase_id(job_id: &str, hash: i64) -> Option { match hash { PREVIEW_IS_CODEBASE_HASH => Some(job_id.to_string()), PREVIEW_IS_TAR_CODEBASE_HASH => Some(format!("{}.tar", job_id)), PREVIEW_IS_ESM_CODEBASE_HASH => Some(format!("{}.esm", job_id)), PREVIEW_IS_TAR_ESM_CODEBASE_HASH => Some(format!("{}.esm.tar", job_id)), _ => None, } } pub struct CodebaseInfo { pub is_tar: bool, pub is_esm: bool, } pub fn id_to_codebase_info(id: &str) -> CodebaseInfo { let is_tar = id.ends_with(".tar"); let is_esm = id.contains(".esm"); CodebaseInfo { is_tar, is_esm } } /// Column list for `SELECT ... FROM script` when targeting `Script`. /// Shared across all query sites so a schema change only needs one edit. pub const SCRIPT_COLUMNS: &str = concat!( "workspace_id, hash, path, parent_hashes, summary, description, content, ", "created_by, created_at, archived, schema, deleted, is_template, extra_perms, ", "lock, lock_error_logs, language, kind, tag, envs, ", "dedicated_worker, ws_error_handler_muted, priority, cache_ttl, cache_ignore_s3_path, ", "timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ", "visible_to_runner_only, auto_kind, codebase, has_preprocessor, ", "on_behalf_of, ", "assets, modules, labels, concurrency_key, concurrent_limit, ", "concurrency_time_window_s, debounce_key, debounce_delay_s, runnable_settings_handle", ); #[derive(Serialize, sqlx::FromRow, Debug)] pub struct Script { pub workspace_id: String, pub hash: ScriptHash, pub path: String, pub parent_hashes: Option, pub summary: String, pub description: String, pub content: String, pub created_by: String, pub created_at: chrono::DateTime, pub archived: bool, pub schema: Option, pub deleted: bool, #[serde(skip_serializing_if = "Option::is_none")] pub is_template: Option, pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub lock: Option, pub lock_error_logs: Option, pub language: ScriptLang, pub kind: ScriptKind, pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] pub envs: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ws_error_handler_muted: Option, #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cache_ttl: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cache_ignore_s3_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, #[serde(skip_serializing, default)] pub delete_after_use: Option, #[serde(skip_serializing_if = "Option::is_none")] pub delete_after_secs: Option, #[serde(skip_serializing_if = "Option::is_none")] pub restart_unless_cancelled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub visible_to_runner_only: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auto_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub codebase: Option, #[serde(skip_serializing_if = "Option::is_none")] pub has_preprocessor: Option, /// Derived from `on_behalf_of` on the read paths, not selected. Kept in the response so /// clients written against the old shape keep working. #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(default)] pub on_behalf_of_email: Option, #[serde(skip_serializing_if = "Option::is_none")] pub on_behalf_of: Option, #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(json(nullable))] pub assets: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(json(nullable))] pub modules: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, /// Labels inherited from the parent folder, computed at read time. Not stored on the script row. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub inherited_labels: Option>, #[serde(flatten)] #[sqlx(flatten)] pub runnable_settings: SR, } // Not serializable #[derive(sqlx::FromRow, Debug, Clone)] pub struct ScriptRunnableSettingsHandle { // legacy - for backwards compatibility // don't add new values. pub concurrency_key: Option, pub concurrent_limit: Option, pub concurrency_time_window_s: Option, pub debounce_key: Option, pub debounce_delay_s: Option, // add here as well. pub runnable_settings_handle: Option, } // Not sqlx queriable #[derive(Serialize, Debug, Clone, Default)] pub struct ScriptRunnableSettingsInline { #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, #[serde(flatten)] pub debouncing_settings: DebouncingSettings, } #[derive(Serialize, sqlx::FromRow)] pub struct ScriptWithStarred { #[sqlx(flatten)] #[serde(flatten)] pub script: Script, #[serde(skip_serializing_if = "Option::is_none")] pub starred: Option, } #[derive(Serialize, sqlx::FromRow)] pub struct ListableScript { pub hash: ScriptHash, pub path: String, pub summary: String, pub created_at: chrono::DateTime, pub archived: bool, pub extra_perms: serde_json::Value, pub language: ScriptLang, pub starred: bool, pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// `Some(true)` only on rows synthesised from the `draft` table (never-deployed /// items the user owns a draft for); `None` on deployed rows. Kept on the public /// response so consumers checking `draft_only === true` keep working. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, pub has_deploy_errors: bool, pub ws_error_handler_muted: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auto_kind: Option, #[serde(skip_serializing_if = "is_false")] pub use_codebase: bool, #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub deployment_msg: Option, pub kind: ScriptKind, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, /// `true` when this entry is the authed user's draft — draft-only, or a deployed /// row the user has saved a draft on top of. Distinguishes user state from team state. #[serde(skip_serializing_if = "is_false")] pub is_draft: bool, /// User-typed staged path, so the home list shows a meaningful name over the /// autogenerated `u/{user}/draft_{uuid}`. Sourced from the draft JSON: scripts use /// `value.path` (the Path widget binds `script.path`); flows/apps/raw apps use an /// explicit `value.draft_path` written only when it differs from deployed. `None` = unchanged. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_path: Option, /// Per-path draft owners (`{ username }`, `None` for the legacy NULL-email row), /// driving the home-page avatar circles. `None` when no drafts; never an empty array. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub inherited_labels: Option>, } fn is_false(x: &bool) -> bool { return !x; } #[derive(Serialize)] pub struct ScriptHistory { pub script_hash: ScriptHash, #[serde(skip_serializing_if = "Option::is_none")] pub deployment_msg: Option, #[serde(skip_serializing_if = "Option::is_none")] pub created_at: Option>, } #[derive(Deserialize)] pub struct ScriptHistoryUpdate { pub deployment_msg: Option, } #[derive(Serialize, Deserialize, Debug, sqlx::Type, Clone)] #[sqlx(transparent)] #[serde(transparent)] pub struct Schema(pub sqlx::types::Json>); impl Hash for Schema { fn hash(&self, state: &mut H) { self.0.get().hash(state); } } #[derive(Serialize, Deserialize, Debug)] pub struct NewScript { pub path: String, pub parent_hash: Option, pub summary: String, #[serde(default)] pub description: String, pub content: String, pub schema: Option, pub is_template: Option, #[serde(default = "Option::default")] #[serde(deserialize_with = "lock_deserialize")] pub lock: Option, pub language: ScriptLang, pub kind: Option, pub tag: Option, pub envs: Option>, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, #[serde(flatten)] pub debouncing_settings: DebouncingSettings, pub cache_ttl: Option, pub cache_ignore_s3_path: Option, pub dedicated_worker: Option, pub ws_error_handler_muted: Option, pub priority: Option, pub timeout: Option, #[serde(skip_serializing, default)] pub delete_after_use: Option, pub delete_after_secs: Option, pub restart_unless_cancelled: Option, pub deployment_message: Option, #[serde(skip_serializing_if = "Option::is_none")] pub visible_to_runner_only: Option, pub auto_kind: Option, pub codebase: Option, pub has_preprocessor: Option, pub on_behalf_of_email: Option, /// Authorization identity to run as, paired with `on_behalf_of_email`. Both move /// together under the same `preserve_on_behalf_of` gate and must name the same user /// or group; `None` has it derived from that email rather than left unset. pub on_behalf_of: Option, pub preserve_on_behalf_of: Option, #[serde(skip_serializing_if = "Option::is_none")] pub assets: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub modules: Option>, #[serde(default)] pub auto_parent: Option, #[serde(default)] pub labels: Option>, /// Caller-intent flag (set by the CLI / git sync): when true, deploying /// this script must NOT delete an existing user draft at the same path. /// Transient — never persisted. Deliberately excluded from `impl Hash` /// below (it must not affect the version hash) and from the no-op /// comparison in the deploy handler (it isn't part of what the script /// *is*). See `is_noop_deploy_against_parent`. #[serde(default, skip_serializing_if = "Option::is_none")] pub skip_draft_deletion: Option, } // IMPORTANT: update this Hash impl when adding fields to NewScript // (exception: caller-intent flags like `skip_draft_deletion` are intentionally // omitted — they must not influence the computed version hash) impl Hash for NewScript { fn hash(&self, state: &mut H) { self.path.hash(state); self.parent_hash.hash(state); self.summary.hash(state); self.description.hash(state); self.content.hash(state); self.schema.hash(state); self.is_template.hash(state); self.lock.hash(state); self.language.hash(state); self.kind.hash(state); self.tag.hash(state); self.envs.hash(state); self.concurrency_settings.hash(state); self.debouncing_settings.hash(state); self.cache_ttl.hash(state); self.cache_ignore_s3_path.hash(state); self.dedicated_worker.hash(state); self.ws_error_handler_muted.hash(state); self.priority.hash(state); self.timeout.hash(state); self.delete_after_use.hash(state); self.restart_unless_cancelled.hash(state); self.deployment_message.hash(state); self.visible_to_runner_only.hash(state); self.auto_kind.hash(state); self.codebase.hash(state); self.has_preprocessor.hash(state); self.on_behalf_of_email.hash(state); self.on_behalf_of.hash(state); self.preserve_on_behalf_of.hash(state); self.assets.hash(state); self.labels.hash(state); if let Some(modules) = &self.modules { let mut sorted: Vec<_> = modules.iter().collect(); sorted.sort_by_key(|(k, _)| *k); for (k, v) in sorted { k.hash(state); v.hash(state); } } } } fn lock_deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::de::Deserializer<'de>, { struct StringOrArrayVisitor; impl<'de> serde::de::Visitor<'de> for StringOrArrayVisitor { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("either a string or an array of strings") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { Ok(Some(v.to_string())) } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_seq(self, mut seq: A) -> Result where A: serde::de::SeqAccess<'de>, { let mut split_lock: Vec = vec![]; loop { if let Ok(Some(elem)) = seq.next_element::() { split_lock.push(elem); } else { break; } } let lock = split_lock.join("\n"); return Ok(Some(lock)); } } deserializer.deserialize_any(StringOrArrayVisitor) } #[derive(Debug, Deserialize)] pub struct ListScriptQuery { pub without_description: Option, pub path_start: Option, pub path_exact: Option, pub created_by: Option, pub first_parent_hash: Option, pub last_parent_hash: Option, pub parent_hash: Option, pub show_archived: Option, pub order_by: Option, pub order_desc: Option, pub is_template: Option, pub kinds: Option, pub starred_only: Option, pub include_without_main: Option, pub include_draft_only: Option, pub with_deployment_msg: Option, #[serde(default, deserialize_with = "from_seq")] pub languages: Option>, pub dedicated_worker: Option, pub label: Option, } fn from_seq<'de, D>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, { let s = ::deserialize(deserializer)?; let languages: Vec = s .split(",") .map(ScriptLang::from_str) .try_collect() .map_err(|e: anyhow::Error| serde::de::Error::custom(e.to_string()))?; let languages = if languages.is_empty() { None } else { Some(languages) }; Ok(languages) } pub fn to_i64(s: &str) -> anyhow::Result { let v = hex::decode(s)?; if v.len() < 8 { return Err(anyhow::anyhow!("hex string did not decode to an u64: {s}",)); } let nb: u64 = u64::from_be_bytes( v[0..8] .try_into() .map_err(|_| hex::FromHexError::InvalidStringLength)?, ); Ok(nb as i64) } pub fn to_hex_string(i: &i64) -> String { hex::encode(i.to_be_bytes()) } #[derive(Deserialize, Serialize)] pub struct HubScript { pub content: String, pub lockfile: Option, pub language: ScriptLang, pub schema: Box, pub summary: Option, } pub fn hash_script(ns: impl std::hash::Hash) -> i64 { let mut dh = std::hash::DefaultHasher::new(); ns.hash(&mut dh); dh.finish() as i64 }