ordinary-action 0.6.0

Action bindings and runtime for Ordinary
Documentation
#![doc = include_str!("../README.md")]
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::missing_errors_doc, clippy::cast_sign_loss)]

// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

mod ffi;

use bytes::Bytes;
use hashbrown::{HashMap, HashSet};
use http::StatusCode;
use parking_lot::RwLock;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{Instrument, instrument};

use ordinary_auth::Auth;
use ordinary_config::{
    ActionAccessAuthOps, ActionAccessModelOps, ActionAccessPermission, ActionConfig,
    ActionFfiVersion, ContentDefinition, ModelConfig,
};
use ordinary_integration::Integration;
use ordinary_storage::{ArtifactKind, CacheRead, Storage};
use ordinary_types::{Field, Kind};

pub use wasmtime::Engine;
use wasmtime::Module;

#[derive(Clone, Debug, PartialEq)]
pub enum ActionResult {
    Result(Bytes),
    StatusCode(StatusCode),
}

#[derive(Clone)]
pub struct PrivilegedComponents {
    pub auth: Arc<Auth>,
    pub app_domains: Arc<Vec<String>>,
    pub apps_dir: PathBuf,
}

#[allow(clippy::struct_excessive_bools)]
#[derive(Clone)]
pub struct Action {
    pub idx: u8,
    pub config: ActionConfig,

    module: Arc<RwLock<Option<Module>>>,
    engine: Engine,

    auth: Arc<Auth>,
    storage: Arc<Storage>,
    integrations: Arc<Vec<Integration>>,

    has_integrations: bool,
    allowed_integrations: Arc<HashMap<u8, Kind>>,
    has_actions: bool,
    allowed_actions: Arc<HashSet<u8>>,

    has_content_defs: bool,
    allowed_content_defs: Arc<HashSet<u8>>,

    #[allow(clippy::type_complexity)]
    allowed_model_ops: Arc<HashMap<u8, (Vec<ActionAccessModelOps>, Vec<Field>)>>,

    can_auth_set_token_fields: bool,

    can_model_insert: bool,
    can_model_update: bool,
    can_model_delete: bool,

    can_model_get: bool,
    can_model_query: bool,
    can_model_search: bool,

    privileged_components: Option<PrivilegedComponents>,
}

impl Action {
    #[instrument(skip_all, fields(i, nm), err)]
    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
    pub fn new(
        src: Option<Bytes>,
        engine: Engine,
        config: ActionConfig,
        auth: Arc<Auth>,
        storage: Arc<Storage>,
        integrations: Arc<Vec<Integration>>,
        model_map: &HashMap<String, ModelConfig>,
        content_map: &HashMap<String, ContentDefinition>,
        action_configs: &Option<Vec<ActionConfig>>,
        privileged_components: Option<PrivilegedComponents>,
    ) -> anyhow::Result<Self> {
        tracing::Span::current().record("i", config.idx);
        tracing::Span::current().record("nm", tracing::field::display(&config.name));

        let module = if let Some(src) = src
            && let Ok(module) = Module::new(&engine, src)
        {
            Arc::new(RwLock::new(Some(module)))
        } else {
            Arc::new(RwLock::new(None))
        };

        let mut has_integrations = false;
        let mut allowed_integrations = HashMap::new();

        let mut has_actions = false;
        let mut allowed_actions = HashSet::new();

        let mut has_content_defs = false;
        let mut allowed_content_defs = HashSet::new();

        let mut allowed_model_ops = HashMap::new();

        let mut can_auth_set_token_fields = false;

        let mut can_model_insert = false;
        let mut can_model_update = false;
        let mut can_model_delete = false;

        let mut can_model_get = false;
        let mut can_model_query = false;
        let mut can_model_search = false;

        for access in &config.access {
            match access {
                ActionAccessPermission::Auth { ops } => {
                    for op in ops {
                        match op {
                            ActionAccessAuthOps::SetTokenFields => {
                                can_auth_set_token_fields = true;
                            }
                        }
                    }
                }
                ActionAccessPermission::Integration { name } => {
                    has_integrations = true;

                    for integration in integrations.iter() {
                        if integration.config.name == *name {
                            allowed_integrations
                                .insert(integration.config.idx, integration.config.send.clone());
                        }
                    }
                }
                ActionAccessPermission::Model { name, ops } => {
                    if let Some(model_config) = model_map.get(name)
                        && model_config.name == *name
                    {
                        for op in ops {
                            match op {
                                ActionAccessModelOps::Insert => can_model_insert = true,
                                ActionAccessModelOps::Update => can_model_update = true,
                                ActionAccessModelOps::Delete => can_model_delete = true,

                                ActionAccessModelOps::Get => can_model_get = true,
                                ActionAccessModelOps::Query => can_model_query = true,
                                ActionAccessModelOps::Search => can_model_search = true,
                            }
                        }

                        let mut fields = model_config.fields.clone();
                        fields.push(Field {
                            idx: 0,
                            name: "uuid".into(),
                            kind: Kind::Uuid,
                            indexed: Some(true),
                            queryable: None,
                            searchable: None,
                            mapping: None,
                            doc: None,
                            encrypted: None,
                            compressed: None,
                        });
                        fields.sort_by_key(|a| a.idx);

                        allowed_model_ops.insert(model_config.idx, (ops.clone(), fields));
                    }
                }
                ActionAccessPermission::Content { name } => {
                    has_content_defs = true;

                    if let Some(def) = content_map.get(name) {
                        allowed_content_defs.insert(def.idx);
                    }
                }
                ActionAccessPermission::Action { name } => {
                    has_actions = true;

                    if let Some(action_configs) = &action_configs {
                        for action in action_configs {
                            if action.name == *name {
                                allowed_actions.insert(action.idx);
                            }
                        }
                    }
                }
            }
        }

        Ok(Self {
            idx: config.idx,
            config,

            module,
            engine,

            auth,
            storage,
            integrations,

            has_actions,
            allowed_actions: Arc::new(allowed_actions),

            has_integrations,
            allowed_integrations: Arc::new(allowed_integrations),

            has_content_defs,
            allowed_content_defs: Arc::new(allowed_content_defs),

            allowed_model_ops: Arc::new(allowed_model_ops),

            can_auth_set_token_fields,

            can_model_insert,
            can_model_update,
            can_model_delete,

            can_model_get,
            can_model_query,
            can_model_search,

            privileged_components,
        })
    }

    #[instrument(skip_all, err)]
    pub async fn set_wasm(&self, src: &[u8]) -> anyhow::Result<()> {
        let storage_span = tracing::info_span!("storage");
        let span = storage_span.in_scope(|| tracing::info_span!("artifact"));

        span.in_scope(|| {
            self.storage
                .artifact
                .put(self.idx, ArtifactKind::Action, src)
        })?;

        {
            let mut lock = self.module.write();
            let module = Module::new(&self.engine, src)?;

            *lock = Some(module);
        }

        let span = storage_span.in_scope(|| tracing::info_span!("cache"));

        async {
            self.storage
                .cache
                .artifact_evict(CacheRead::Action, self.idx)
                .await
        }
        .instrument(span)
        .await?;

        Ok(())
    }

    #[allow(clippy::missing_panics_doc, clippy::too_many_lines)]
    #[instrument(skip_all, err)]
    pub fn call(&self, args: &[u8], actions: &Arc<Vec<Action>>) -> anyhow::Result<ActionResult> {
        match self.config.ffi.version {
            ActionFfiVersion::V1 => ffi::v1::call(self, args, actions),
        }
    }
}