//! Persistence for workflow definitions (v1.2 Workflows). //! //! The `workflows` table (0071) is owner-polymorphic like `scripts`; M1 authors //! **app-owned** workflows only (the `group_id` column ships unused). The read //! trait backs the admin/CLI surface; the `*_tx` free-fns are used by the //! declarative `apply` reconcile engine (Create/Update/Delete by `lower(name)`, //! all in one transaction) — mirroring `trigger_repo::insert_trigger_tx`. //! //! Run/step state (`workflow_runs`, `workflow_run_steps`) is added in M2 with //! the orchestrator. use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use thiserror::Error; use uuid::Uuid; use picloud_shared::workflow::WorkflowDefinition; use picloud_shared::{AppId, WorkflowId}; #[derive(Debug, Error)] pub enum WorkflowRepoError { #[error(transparent)] Db(#[from] sqlx::Error), #[error("workflow definition (de)serialization failed: {0}")] Serde(String), } /// A stored workflow definition (app-owned in M1). #[derive(Debug, Clone)] pub struct Workflow { pub id: WorkflowId, pub app_id: AppId, pub name: String, pub definition: WorkflowDefinition, pub enabled: bool, pub created_at: DateTime, pub updated_at: DateTime, } /// Raw row shape (definition decoded separately). #[derive(sqlx::FromRow)] struct WorkflowRow { id: Uuid, app_id: Uuid, name: String, definition: serde_json::Value, enabled: bool, created_at: DateTime, updated_at: DateTime, } impl TryFrom for Workflow { type Error = WorkflowRepoError; fn try_from(r: WorkflowRow) -> Result { Ok(Self { id: r.id.into(), app_id: r.app_id.into(), name: r.name, definition: serde_json::from_value(r.definition) .map_err(|e| WorkflowRepoError::Serde(e.to_string()))?, enabled: r.enabled, created_at: r.created_at, updated_at: r.updated_at, }) } } const SELECT_COLS: &str = "id, app_id, name, definition, enabled, created_at, updated_at"; #[async_trait] pub trait WorkflowRepo: Send + Sync { /// All workflows owned by `app_id`, ordered by name. async fn list_for_app(&self, app_id: AppId) -> Result, WorkflowRepoError>; /// A single app-owned workflow by case-insensitive name. async fn get_by_name( &self, app_id: AppId, name: &str, ) -> Result, WorkflowRepoError>; /// A single workflow by id (app-scoped for isolation). async fn get_by_id( &self, app_id: AppId, id: WorkflowId, ) -> Result, WorkflowRepoError>; } pub struct PostgresWorkflowRepo { pool: PgPool, } impl PostgresWorkflowRepo { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl WorkflowRepo for PostgresWorkflowRepo { async fn list_for_app(&self, app_id: AppId) -> Result, WorkflowRepoError> { let rows: Vec = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)" )) .bind(app_id.into_inner()) .fetch_all(&self.pool) .await?; rows.into_iter().map(TryInto::try_into).collect() } async fn get_by_name( &self, app_id: AppId, name: &str, ) -> Result, WorkflowRepoError> { let row: Option = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND LOWER(name) = LOWER($2)" )) .bind(app_id.into_inner()) .bind(name) .fetch_optional(&self.pool) .await?; row.map(TryInto::try_into).transpose() } async fn get_by_id( &self, app_id: AppId, id: WorkflowId, ) -> Result, WorkflowRepoError> { let row: Option = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND id = $2" )) .bind(app_id.into_inner()) .bind(id.into_inner()) .fetch_optional(&self.pool) .await?; row.map(TryInto::try_into).transpose() } } /// Pool-based read of an app's workflows (used by `apply`'s `load_current`, /// which reads several markers directly off the pool). pub async fn list_workflows_for_app( pool: &PgPool, app_id: AppId, ) -> Result, WorkflowRepoError> { let rows: Vec = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)" )) .bind(app_id.into_inner()) .fetch_all(pool) .await?; rows.into_iter().map(TryInto::try_into).collect() } // ---- reconcile tx free-fns (used by apply_service) ------------------------ /// Load an app's workflows inside the apply transaction (so the diff sees a /// snapshot consistent with the writes). pub async fn list_workflows_for_app_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, app_id: AppId, ) -> Result, WorkflowRepoError> { let rows: Vec = sqlx::query_as(&format!( "SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 ORDER BY LOWER(name)" )) .bind(app_id.into_inner()) .fetch_all(&mut **tx) .await?; rows.into_iter().map(TryInto::try_into).collect() } /// Insert an app-owned workflow. pub async fn insert_workflow_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, app_id: AppId, name: &str, definition: &WorkflowDefinition, enabled: bool, ) -> Result { let def = serde_json::to_value(definition).map_err(|e| WorkflowRepoError::Serde(e.to_string()))?; let (id,): (Uuid,) = sqlx::query_as( "INSERT INTO workflows (app_id, name, definition, enabled) \ VALUES ($1, $2, $3, $4) RETURNING id", ) .bind(app_id.into_inner()) .bind(name) .bind(def) .bind(enabled) .fetch_one(&mut **tx) .await?; Ok(id.into()) } /// Update a workflow's definition + enabled flag (name/identity unchanged). pub async fn update_workflow_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, id: WorkflowId, definition: &WorkflowDefinition, enabled: bool, ) -> Result<(), WorkflowRepoError> { let def = serde_json::to_value(definition).map_err(|e| WorkflowRepoError::Serde(e.to_string()))?; sqlx::query( "UPDATE workflows SET definition = $2, enabled = $3, updated_at = NOW() WHERE id = $1", ) .bind(id.into_inner()) .bind(def) .bind(enabled) .execute(&mut **tx) .await?; Ok(()) } /// Delete a workflow (prune). RESTRICTs if runs reference it — a workflow with /// history is kept; this is surfaced as an error to the reconcile caller. pub async fn delete_workflow_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, id: WorkflowId, ) -> Result<(), WorkflowRepoError> { sqlx::query("DELETE FROM workflows WHERE id = $1") .bind(id.into_inner()) .execute(&mut **tx) .await?; Ok(()) }