lenso-platform-core 0.1.0

Core runtime primitives for the Lenso backend framework.
Documentation
use crate::context::{ActorContext, RequestContext};
use crate::db::DbPool;
use crate::error::{AppError, AppResult, ErrorCode};
use chrono::{DateTime, Utc};
use serde_json::{Value, json};

#[derive(Debug, Clone)]
#[doc(hidden)]
pub struct HttpRequestStoryEventRecord {
    pub method: String,
    pub path: String,
    pub status_code: u16,
    pub error_code: Option<String>,
    pub creation: HttpRequestStoryCreation,
    pub started_at: DateTime<Utc>,
    pub completed_at: DateTime<Utc>,
    pub duration_ms: i64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub enum HttpRequestStoryCreation {
    Never,
    Always,
    WhenRuntimeWorkExists,
}

#[doc(hidden)]
pub async fn insert_http_request_story_projection(
    pool: &DbPool,
    request_ctx: &RequestContext,
    record: HttpRequestStoryEventRecord,
) -> AppResult<String> {
    let id = http_request_story_event_id(request_ctx);
    if !should_insert_http_request_story(pool, request_ctx, &record, &id).await? {
        return Ok(id);
    }

    sqlx::query(
        r#"
        insert into platform.story_events (
            id,
            source_type,
            source_id,
            node_type,
            name,
            status,
            service,
            correlation_id,
            causation_id,
            started_at,
            completed_at,
            duration_ms,
            error,
            metadata,
            trace_id,
            span_id,
            updated_at
        )
        values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
        on conflict (source_type, source_id) do update
        set
            status = excluded.status,
            completed_at = excluded.completed_at,
            duration_ms = excluded.duration_ms,
            error = excluded.error,
            metadata = excluded.metadata,
            trace_id = excluded.trace_id,
            span_id = excluded.span_id,
            updated_at = excluded.updated_at
        "#,
    )
    .bind(&id)
    .bind("http_request")
    .bind(&request_ctx.request_id.0)
    .bind("http_request")
    .bind(format!("{} {}", record.method, record.path))
    .bind(http_request_status(&record))
    .bind("api")
    .bind(&request_ctx.correlation_id.0)
    .bind(None::<String>)
    .bind(record.started_at)
    .bind(record.completed_at)
    .bind(record.duration_ms)
    .bind(http_request_error(&record))
    .bind(http_request_metadata(request_ctx, &record))
    .bind(&request_ctx.trace.trace_id)
    .bind(&request_ctx.trace.span_id)
    .bind(record.completed_at)
    .execute(pool)
    .await
    .map_err(map_story_event_error)?;

    Ok(id)
}

async fn should_insert_http_request_story(
    pool: &DbPool,
    request_ctx: &RequestContext,
    record: &HttpRequestStoryEventRecord,
    story_event_id: &str,
) -> AppResult<bool> {
    match record.creation {
        HttpRequestStoryCreation::Never => Ok(false),
        HttpRequestStoryCreation::Always => Ok(true),
        HttpRequestStoryCreation::WhenRuntimeWorkExists => {
            request_has_runtime_work(pool, request_ctx, story_event_id).await
        }
    }
}

async fn request_has_runtime_work(
    pool: &DbPool,
    request_ctx: &RequestContext,
    story_event_id: &str,
) -> AppResult<bool> {
    let has_outbox_work = sqlx::query_scalar::<_, bool>(
        r#"
        select exists (
            select 1
            from platform.outbox
            where correlation_id = $1
                and causation_id = $2
        )
        "#,
    )
    .bind(&request_ctx.correlation_id.0)
    .bind(story_event_id)
    .fetch_one(pool)
    .await
    .map_err(map_story_event_error)?;

    if has_outbox_work {
        return Ok(true);
    }

    let function_work = sqlx::query_scalar::<_, bool>(
        r#"
        select exists (
            select 1
            from runtime.function_runs
            where correlation_id = $1
                and input_json #>> '{_lenso_runtime,causation_id}' = $2
        )
        "#,
    )
    .bind(&request_ctx.correlation_id.0)
    .bind(story_event_id)
    .fetch_one(pool)
    .await;

    match function_work {
        Ok(has_function_work) => Ok(has_function_work),
        Err(error) if is_missing_runtime_relation(&error) => Ok(false),
        Err(error) => Err(map_story_event_error(error)),
    }
}

fn http_request_status(record: &HttpRequestStoryEventRecord) -> &'static str {
    if record.status_code >= 400 {
        "failed"
    } else {
        "completed"
    }
}

fn http_request_error(record: &HttpRequestStoryEventRecord) -> Option<String> {
    if record.status_code < 400 {
        return None;
    }

    Some(match record.error_code.as_deref() {
        Some(error_code) => format!("HTTP {} {error_code}", record.status_code),
        None => format!("HTTP request failed with status {}", record.status_code),
    })
}

pub fn http_request_story_event_id(request_ctx: &RequestContext) -> String {
    format!("httpreq_{}", request_ctx.request_id.0)
}

pub fn http_request_story_creation(path: &str, status_code: u16) -> HttpRequestStoryCreation {
    if is_console_or_internal_path(path) {
        return HttpRequestStoryCreation::Never;
    }
    if is_remote_proxy_path(path) {
        return HttpRequestStoryCreation::Always;
    }
    if status_code >= 500 {
        return HttpRequestStoryCreation::Always;
    }
    if status_code < 400 {
        return if path.starts_with("/v1/") {
            HttpRequestStoryCreation::WhenRuntimeWorkExists
        } else {
            HttpRequestStoryCreation::Never
        };
    }

    if path.starts_with("/v1/") && matches!(status_code, 400 | 403 | 409 | 422) {
        HttpRequestStoryCreation::Always
    } else {
        HttpRequestStoryCreation::Never
    }
}

fn is_console_or_internal_path(path: &str) -> bool {
    path.starts_with("/admin/runtime")
        || path.starts_with("/console/")
        || path == "/docs"
        || path == "/openapi.json"
        || path.ends_with("/health")
}

fn is_remote_proxy_path(path: &str) -> bool {
    path.starts_with("/modules/") && path.contains("/http/")
}

fn http_request_metadata(
    request_ctx: &RequestContext,
    record: &HttpRequestStoryEventRecord,
) -> Value {
    json!({
        "request_id": request_ctx.request_id.0,
        "method": record.method,
        "path": record.path,
        "status_code": record.status_code,
        "error_code": record.error_code,
        "duration_ms": record.duration_ms,
        "actor_kind": actor_kind(&request_ctx.actor),
    })
}

fn actor_kind(actor: &ActorContext) -> &'static str {
    match actor {
        ActorContext::Anonymous => "anonymous",
        ActorContext::User { .. } => "user",
        ActorContext::Service { .. } => "service",
        ActorContext::System => "system",
    }
}

fn map_story_event_error(source: sqlx::Error) -> AppError {
    AppError::new(ErrorCode::Internal, "Story event operation failed").with_source(source)
}

fn is_missing_runtime_relation(error: &sqlx::Error) -> bool {
    let Some(database_error) = error.as_database_error() else {
        return false;
    };

    matches!(database_error.code().as_deref(), Some("3F000" | "42P01"))
}

#[cfg(test)]
mod tests {
    use super::{HttpRequestStoryCreation, http_request_story_creation};

    #[test]
    fn remote_proxy_success_creates_story_root() {
        assert_eq!(
            http_request_story_creation("/modules/remote-crm/http/contacts/contact_1", 200),
            HttpRequestStoryCreation::Always
        );
    }

    #[test]
    fn remote_proxy_client_error_creates_story_root() {
        assert_eq!(
            http_request_story_creation("/modules/remote-crm/http/contacts/contact_404", 404),
            HttpRequestStoryCreation::Always
        );
    }

    #[test]
    fn remote_proxy_dependency_error_creates_story_root() {
        assert_eq!(
            http_request_story_creation("/modules/remote-crm/http/proxy-fixtures/text", 502),
            HttpRequestStoryCreation::Always
        );
    }

    #[test]
    fn admin_runtime_paths_do_not_create_story_roots() {
        assert_eq!(
            http_request_story_creation("/admin/runtime/stories/corr_1", 200),
            HttpRequestStoryCreation::Never
        );
    }

    #[test]
    fn console_asset_paths_do_not_create_story_roots() {
        assert_eq!(
            http_request_story_creation("/console/extensions/crm/entry.js", 200),
            HttpRequestStoryCreation::Never
        );
    }

    #[test]
    fn v1_success_still_waits_for_runtime_work() {
        assert_eq!(
            http_request_story_creation("/v1/identity/users", 200),
            HttpRequestStoryCreation::WhenRuntimeWorkExists
        );
    }
}