Skip to main content

mini_app_core/
alias_run.rs

1//! Top-level orchestration for the `alias_run` MCP tool.
2//!
3//! This module exposes [`execute_alias_run`] as the single entry point for
4//! running a named alias. Both the MCP tool handler and direct SDK consumers
5//! call this function; the MCP handler is a thin wrapper that:
6//! 1. Resolves the [`AliasRecord`] from global or per-table storage.
7//! 2. Calls [`execute_alias_run`].
8//! 3. Serialises the [`AliasRunValue`] result to JSON (backward-compat shape).
9//!
10//! # Crux compliance
11//!
12//! - **Crux #1 / #2**: `crates/core/Cargo.toml` must not declare `rmcp` as a
13//!   dependency. All types here are pure Core-native types. MCP boundary
14//!   conversions (e.g. `MiniAppError → rmcp::ErrorData`) are the sole
15//!   responsibility of private adapter functions in `crates/mcp`.
16//!
17//! # MiniJinja render pipeline (Crux §1/#2)
18//!
19//! When `record.params_schema` is `Some`, the `filter` field is a MiniJinja
20//! template. The template is rendered with `params` as the context, then the
21//! rendered string is parsed as a [`ListFilter`] JSON document. When
22//! `params_schema` is `None`, the render step is skipped entirely for backward
23//! compatibility with plain-JSON aliases.
24
25use std::sync::Arc;
26
27use serde::Serialize;
28
29use crate::aggregator::{AliasAggregator, AliasRunResult, SourceSpec, execute_aggregate};
30use crate::alias_storage::AliasRecord;
31use crate::error::MiniAppError;
32use crate::filter::ListFilter;
33use crate::materialize::{FieldSelector, apply_projection};
34use crate::order_by::OrderByItem;
35use crate::registry::TableRegistry;
36
37// =============================================================================
38// Result type
39// =============================================================================
40
41/// The result of [`execute_alias_run`].
42///
43/// Wraps both the plain Rows path (per-table `store.list` + field projection)
44/// and the Aggregator path (`execute_aggregate`). MCP callers serialise each
45/// variant with its natural JSON shape to preserve backward compatibility:
46///
47/// - `Rows(records)` → `serde_json::to_string(&records)`
48/// - `Aggregate(result)` → `serde_json::to_string(&result)`
49#[derive(Debug, Serialize)]
50pub enum AliasRunValue {
51    /// Plain rows path — result of `store.list` + field projection.
52    Rows(Vec<crate::store::RowRecord>),
53    /// Aggregator path — wraps the existing [`AliasRunResult`].
54    Aggregate(AliasRunResult),
55}
56
57// =============================================================================
58// Public entry point
59// =============================================================================
60
61/// Execute an alias and return the typed result.
62///
63/// This is the canonical alias_run implementation. Both the MCP `alias_run`
64/// tool handler and direct SDK consumers call this function.
65///
66/// # Arguments
67///
68/// - `registry` — Live [`TableRegistry`]; used to resolve store handles and
69///   schema configs from table names, and to collect table names for
70///   [`SourceSpec::Pattern`] resolution.
71/// - `record` — The [`AliasRecord`] that describes the alias (sources,
72///   aggregator, filter template, parameter schema, default limit).
73/// - `params` — Optional JSON value used as the MiniJinja render context.
74///   Required when `record.params_schema` is `Some`; ignored (and silently
75///   accepted) when `None`.
76/// - `table_fallback` — Legacy single-table mode: the `table` argument
77///   supplied to `alias_run`. Used when `record.sources` is
78///   `SourceSpec::Single` with an empty placeholder produced by the legacy
79///   per-table path. Ignored when `record.sources` is already a fully
80///   populated `Single`/`Multi`/`Pattern`.
81/// - `limit_override` — Caller-supplied row limit. Falls back to
82///   `record.default_limit` when `None`.
83/// - `offset` — Number of rows to skip (plain Rows path only).
84/// - `fields` — Field projection selector (plain Rows path only).
85///
86/// # Errors
87///
88/// - [`MiniAppError::AliasParamsRequired`] — `params_schema` is `Some` but
89///   `params` is `None`.
90/// - [`MiniAppError::AliasTemplateError`] — MiniJinja render failure.
91/// - [`MiniAppError::Filter`] — filter parse or validate failure.
92/// - [`MiniAppError::Aggregator`] — aggregator execute failure.
93/// - [`MiniAppError::TableNotFound`] — a referenced table is not in the
94///   registry.
95/// - [`MiniAppError::Storage`] — underlying SQLite error.
96#[allow(clippy::too_many_arguments)]
97pub async fn execute_alias_run(
98    registry: &TableRegistry,
99    record: AliasRecord,
100    params: Option<serde_json::Value>,
101    table_fallback: Option<&str>,
102    limit_override: Option<u32>,
103    offset: Option<u32>,
104    fields: Option<FieldSelector>,
105    order_by_override: Option<Vec<OrderByItem>>,
106) -> Result<AliasRunValue, MiniAppError> {
107    // -----------------------------------------------------------------
108    // Step 1: Render the filter template (Crux #1/#2).
109    // -----------------------------------------------------------------
110    let filter_text = record.filter;
111    let filter: ListFilter = if record.params_schema.is_some() {
112        let mut params_value = params.ok_or_else(|| MiniAppError::AliasParamsRequired {
113            name: record.name.clone(),
114        })?;
115        // Defensive parse: some MCP transports (notably the Claude Code
116        // stdio client) stringify `serde_json::Value` argument fields, so
117        // a `params: {"k": "v"}` payload arrives here as
118        // `Value::String("{\"k\":\"v\"}")` rather than `Value::Object(..)`.
119        // When minijinja receives a String it cannot resolve `{{ k }}`
120        // (the String has no keys), the placeholder evaluates to undefined
121        // and lenient render emits an empty value. Re-parse JSON-encoded
122        // strings into their Object form so render works regardless of
123        // the transport.
124        if let serde_json::Value::String(ref s) = params_value {
125            params_value = serde_json::from_str(s).map_err(|e| {
126                MiniAppError::AliasTemplateError(format!(
127                    "params arrived as a string but failed JSON parse: {e}"
128                ))
129            })?;
130        }
131        let env = minijinja::Environment::new();
132        let rendered = env
133            .render_str(&filter_text, &params_value)
134            .map_err(|e| MiniAppError::AliasTemplateError(e.to_string()))?;
135        serde_json::from_str(&rendered)
136            .map_err(|e| MiniAppError::Schema(format!("rendered filter parse error: {e}")))?
137    } else {
138        serde_json::from_str(&filter_text)
139            .map_err(|e| MiniAppError::Schema(format!("filter parse error: {e}")))?
140    };
141
142    let limit = limit_override.or(record.default_limit);
143
144    // -----------------------------------------------------------------
145    // Crux #2: Field-projection fallback.
146    //
147    // When the caller supplies `fields` at run-time, use it as-is.
148    // When the caller omits `fields` (None), fall back to the stored
149    // default from `record.fields`.
150    // When `record.fields` is also None, no projection is applied — all
151    // fields are returned (Crux #3: NULL must never become an empty list).
152    // -----------------------------------------------------------------
153    let fields = match fields {
154        Some(f) => Some(f),
155        None => match record.fields.as_deref() {
156            Some(json) => Some(serde_json::from_str(json).map_err(|e| {
157                MiniAppError::Schema(format!(
158                    "alias '{}' stored fields parse error: {e}",
159                    record.name
160                ))
161            })?),
162            None => None,
163        },
164    };
165
166    // -----------------------------------------------------------------
167    // Order-by fallback (mirrors the fields fallback above).
168    //
169    // When the caller supplies `order_by_override` at run-time, use it.
170    // Otherwise fall back to the stored default from `record.order_by`.
171    // When both are None, no ORDER BY is applied — the store default
172    // (`ORDER BY created_at DESC`) is used instead.
173    // -----------------------------------------------------------------
174    let order_by = match order_by_override {
175        Some(v) => Some(v),
176        None => match record.order_by.as_deref() {
177            Some(json) => Some(serde_json::from_str(json).map_err(|e| {
178                MiniAppError::Schema(format!(
179                    "alias '{}' stored order_by parse error: {e}",
180                    record.name
181                ))
182            })?),
183            None => None,
184        },
185    };
186
187    // -----------------------------------------------------------------
188    // Step 2: Aggregator path dispatch.
189    // -----------------------------------------------------------------
190    if let Some(agg) = record.aggregator {
191        return execute_aggregator_path(registry, record.sources, filter, agg, limit, order_by)
192            .await;
193    }
194
195    // -----------------------------------------------------------------
196    // Step 3: Plain (Rows) path.
197    // -----------------------------------------------------------------
198    execute_rows_path(
199        registry,
200        record.sources,
201        table_fallback,
202        filter,
203        limit,
204        offset,
205        fields,
206        order_by,
207    )
208    .await
209}
210
211// =============================================================================
212// Internal helpers
213// =============================================================================
214
215/// Aggregator execution path: resolves Pattern sources, validates, and calls
216/// [`execute_aggregate`].
217///
218/// `order_by` is silently ignored on this path — aggregator results have their
219/// own structure and do not map onto a per-row SQL ORDER BY.  A [`tracing::warn!`]
220/// is emitted so callers can diagnose unexpected sort arguments.
221async fn execute_aggregator_path(
222    registry: &TableRegistry,
223    sources: SourceSpec,
224    filter: ListFilter,
225    agg: AliasAggregator,
226    _limit: Option<u32>,
227    order_by: Option<Vec<OrderByItem>>,
228) -> Result<AliasRunValue, MiniAppError> {
229    if order_by.is_some() {
230        tracing::warn!(
231            "alias_run: order_by is set but the alias uses an aggregator — \
232             order_by is silently ignored on the aggregator path"
233        );
234    }
235    let resolved = if sources.requires_resolve() {
236        let table_names: Vec<String> = registry.table_names().map(str::to_owned).collect();
237        sources.resolve_pattern(&table_names)?
238    } else {
239        sources
240    };
241
242    let schema_table =
243        resolved.tables().first().cloned().ok_or_else(|| {
244            MiniAppError::Aggregator("alias sources resolved to zero tables".into())
245        })?;
246
247    let schema = Arc::clone(&registry.resolve(Some(schema_table.as_str()))?.schema);
248
249    filter.validate(&schema)?;
250
251    let result = execute_aggregate(registry, resolved, Some(filter), agg, &schema).await?;
252    Ok(AliasRunValue::Aggregate(result))
253}
254
255/// Plain rows execution path: resolves a single-table store and returns rows
256/// after applying optional field projection and order-by.
257#[allow(clippy::too_many_arguments)]
258async fn execute_rows_path(
259    registry: &TableRegistry,
260    sources: SourceSpec,
261    table_fallback: Option<&str>,
262    filter: ListFilter,
263    limit: Option<u32>,
264    offset: Option<u32>,
265    fields: Option<FieldSelector>,
266    order_by: Option<Vec<OrderByItem>>,
267) -> Result<AliasRunValue, MiniAppError> {
268    let table_name: Option<&str> = match &sources {
269        // Non-empty Single → use it directly.
270        // Empty-string Single is the legacy sentinel produced by the MCP wrapper
271        // when reading from per-table `_aliases`; fall through to `table_fallback`.
272        SourceSpec::Single(t) if !t.is_empty() => Some(t.as_str()),
273        SourceSpec::Single(_) => None,
274        SourceSpec::Multi(_) | SourceSpec::Pattern(_) => {
275            return Err(MiniAppError::Aggregator(
276                "Multi/Pattern source aliases require an aggregator (Phase 2 limitation)".into(),
277            ));
278        }
279    };
280
281    // Use `table_name` from sources when available; otherwise fall back to
282    // the legacy `table_fallback` arg (per-table alias_run path).
283    let effective_table = table_name.or(table_fallback);
284
285    let entry = registry.resolve(effective_table)?;
286    let store = Arc::clone(&entry.store);
287    let schema = Arc::clone(&entry.schema);
288
289    filter.validate(&schema)?;
290    let records = store.list(limit, offset, Some(filter), order_by).await?;
291    let records = apply_projection(records, &fields, &schema)?;
292    Ok(AliasRunValue::Rows(records))
293}
294
295// =============================================================================
296// Tests
297// =============================================================================
298
299#[cfg(test)]
300mod tests {
301    use std::collections::HashMap;
302
303    use tempfile::tempdir;
304
305    use super::*;
306    use crate::aggregator::{AliasAggregator, SourceSpec};
307    use crate::alias_storage::AliasRecord;
308    use crate::registry::{TableEntry, TableRegistry};
309    use crate::schema::{FieldDef, FieldType, SchemaConfig};
310    use crate::store::Store;
311
312    // -----------------------------------------------------------------------
313    // Test helpers
314    // -----------------------------------------------------------------------
315
316    /// Minimal schema with a `status` string field (direct struct construction).
317    fn status_schema() -> SchemaConfig {
318        SchemaConfig {
319            table: "items".into(),
320            title: None,
321            description: None,
322            fields: vec![FieldDef {
323                name: "status".into(),
324                ty: FieldType::String,
325                required: false,
326                description: None,
327            }],
328            dump: None,
329            history: Default::default(),
330        }
331    }
332
333    /// Build a tempfile-backed Store with seed rows.
334    async fn make_store_with_rows(schema: &SchemaConfig, rows: Vec<serde_json::Value>) -> Store {
335        let dir = tempdir().expect("tempdir");
336        let db_path = dir.path().join("test.db");
337        let store = Store::open(&db_path, schema.clone())
338            .await
339            .expect("store open");
340        // Leak the tempdir so the db file lives for the test duration.
341        std::mem::forget(dir);
342        for row in rows {
343            store.create(row).await.expect("insert row");
344        }
345        store
346    }
347
348    /// Build a single-table [`TableRegistry`] from a store + schema.
349    fn registry_from_store(table: &str, store: Store, schema: SchemaConfig) -> TableRegistry {
350        let mut entries = HashMap::new();
351        entries.insert(
352            table.to_string(),
353            TableEntry {
354                store: Arc::new(store),
355                schema: Arc::new(schema),
356                schema_path: Arc::new(std::path::PathBuf::new()),
357            },
358        );
359        TableRegistry::from_entries(entries, Some(table.to_string()))
360    }
361
362    /// Build a minimal [`AliasRecord`] (no aggregator, no params, no stored fields).
363    fn plain_alias(sources: SourceSpec, filter_json: &str) -> AliasRecord {
364        AliasRecord {
365            name: "test_alias".into(),
366            sources,
367            aggregator: None,
368            filter: filter_json.into(),
369            default_limit: None,
370            description: None,
371            params_schema: None,
372            fields: None,
373            order_by: None,
374            scope: None,
375        }
376    }
377
378    // -----------------------------------------------------------------------
379    // Test: Rows path — Single source + plain filter
380    // -----------------------------------------------------------------------
381    #[tokio::test]
382    async fn rows_path_single_source() {
383        let schema = status_schema();
384        let store = make_store_with_rows(
385            &schema,
386            vec![
387                serde_json::json!({"status": "open"}),
388                serde_json::json!({"status": "closed"}),
389            ],
390        )
391        .await;
392        let registry = registry_from_store("items", store, schema);
393
394        // Filter: only "open" rows. ListFilter uses {"type":"eq",...} shape.
395        let filter_json = r#"{"type":"eq","field":"status","value":"open"}"#;
396        let record = plain_alias(SourceSpec::Single("items".into()), filter_json);
397
398        let result = execute_alias_run(&registry, record, None, None, None, None, None, None)
399            .await
400            .expect("execute_alias_run");
401
402        match result {
403            AliasRunValue::Rows(rows) => {
404                assert_eq!(rows.len(), 1);
405                assert_eq!(rows[0].data["status"], "open");
406            }
407            other => panic!("expected Rows, got {other:?}"),
408        }
409    }
410
411    // -----------------------------------------------------------------------
412    // Test: Aggregator path — Count
413    // -----------------------------------------------------------------------
414    #[tokio::test]
415    async fn aggregator_path_count() {
416        let schema = status_schema();
417        let store = make_store_with_rows(
418            &schema,
419            vec![
420                serde_json::json!({"status": "open"}),
421                serde_json::json!({"status": "open"}),
422                serde_json::json!({"status": "closed"}),
423            ],
424        )
425        .await;
426        let registry = registry_from_store("items", store, schema);
427
428        // Use an "open" status filter to match 2 of 3 rows.
429        let record = AliasRecord {
430            name: "count_alias".into(),
431            sources: SourceSpec::Single("items".into()),
432            aggregator: Some(AliasAggregator::Count),
433            filter: r#"{"type":"eq","field":"status","value":"open"}"#.into(),
434            default_limit: None,
435            description: None,
436            params_schema: None,
437            fields: None,
438            order_by: None,
439            scope: None,
440        };
441
442        let result = execute_alias_run(&registry, record, None, None, None, None, None, None)
443            .await
444            .expect("execute_alias_run");
445
446        match result {
447            AliasRunValue::Aggregate(AliasRunResult::Count(n)) => assert_eq!(n, 2),
448            other => panic!("expected Aggregate(Count(2)), got {other:?}"),
449        }
450    }
451
452    // -----------------------------------------------------------------------
453    // Test: MiniJinja render — params_schema=Some, template substitution
454    // -----------------------------------------------------------------------
455    #[tokio::test]
456    async fn jinja_render_substitution() {
457        let schema = status_schema();
458        let store = make_store_with_rows(
459            &schema,
460            vec![
461                serde_json::json!({"status": "open"}),
462                serde_json::json!({"status": "closed"}),
463            ],
464        )
465        .await;
466        let registry = registry_from_store("items", store, schema);
467
468        // Template: substitute {{ status }} from params. Uses ListFilter JSON shape.
469        let record = AliasRecord {
470            name: "templated_alias".into(),
471            sources: SourceSpec::Single("items".into()),
472            aggregator: None,
473            filter: r#"{"type":"eq","field":"status","value":"{{ status }}"}"#.into(),
474            default_limit: None,
475            description: None,
476            params_schema: Some(r#"["status"]"#.into()),
477            fields: None,
478            order_by: None,
479            scope: None,
480        };
481
482        let params = serde_json::json!({"status": "closed"});
483
484        let result = execute_alias_run(
485            &registry,
486            record,
487            Some(params),
488            None,
489            None,
490            None,
491            None,
492            None,
493        )
494        .await
495        .expect("execute_alias_run");
496
497        match result {
498            AliasRunValue::Rows(rows) => {
499                assert_eq!(rows.len(), 1);
500                assert_eq!(rows[0].data["status"], "closed");
501            }
502            other => panic!("expected Rows, got {other:?}"),
503        }
504    }
505
506    // -----------------------------------------------------------------------
507    // Test: MCP transport stringified params — Value::String("{...}") path
508    //
509    // Some MCP transports (notably the Claude Code stdio client) deliver
510    // `Option<serde_json::Value>` argument fields as JSON-encoded strings
511    // rather than parsed objects. Verify the defensive re-parse path so
512    // `{{ key }}` still resolves when params arrives as Value::String.
513    // -----------------------------------------------------------------------
514    #[tokio::test]
515    async fn jinja_render_with_stringified_params() {
516        let schema = status_schema();
517        let store = make_store_with_rows(
518            &schema,
519            vec![
520                serde_json::json!({"status": "open"}),
521                serde_json::json!({"status": "closed"}),
522            ],
523        )
524        .await;
525        let registry = registry_from_store("items", store, schema);
526
527        let record = AliasRecord {
528            name: "templated_alias".into(),
529            sources: SourceSpec::Single("items".into()),
530            aggregator: None,
531            filter: r#"{"type":"eq","field":"status","value":"{{ status }}"}"#.into(),
532            default_limit: None,
533            description: None,
534            params_schema: Some(r#"["status"]"#.into()),
535            fields: None,
536            order_by: None,
537            scope: None,
538        };
539
540        // params arrives as Value::String containing a JSON-encoded object
541        // (the failure mode observed via the Claude Code MCP transport).
542        let stringified_params = serde_json::Value::String(r#"{"status": "closed"}"#.to_string());
543
544        let result = execute_alias_run(
545            &registry,
546            record,
547            Some(stringified_params),
548            None,
549            None,
550            None,
551            None,
552            None,
553        )
554        .await
555        .expect("execute_alias_run must re-parse stringified params");
556
557        match result {
558            AliasRunValue::Rows(rows) => {
559                assert_eq!(rows.len(), 1);
560                assert_eq!(rows[0].data["status"], "closed");
561            }
562            other => panic!("expected Rows, got {other:?}"),
563        }
564    }
565
566    // -----------------------------------------------------------------------
567    // Test: Legacy mode fallback — sources Single("") + table_fallback
568    // -----------------------------------------------------------------------
569    #[tokio::test]
570    async fn legacy_mode_table_fallback() {
571        let schema = status_schema();
572        let store =
573            make_store_with_rows(&schema, vec![serde_json::json!({"status": "open"})]).await;
574        let registry = registry_from_store("items", store, schema);
575
576        // Simulate a legacy record where `sources` has an empty Single table
577        // name and `table_fallback` provides the real name.
578        let record = AliasRecord {
579            name: "legacy_alias".into(),
580            sources: SourceSpec::Single(String::new()),
581            aggregator: None,
582            filter: r#"{"type":"eq","field":"status","value":"open"}"#.into(),
583            default_limit: None,
584            description: None,
585            params_schema: None,
586            fields: None,
587            order_by: None,
588            scope: None,
589        };
590
591        let result = execute_alias_run(
592            &registry,
593            record,
594            None,
595            Some("items"),
596            None,
597            None,
598            None,
599            None,
600        )
601        .await
602        .expect("execute_alias_run");
603
604        match result {
605            AliasRunValue::Rows(rows) => assert!(!rows.is_empty()),
606            other => panic!("expected Rows, got {other:?}"),
607        }
608    }
609
610    // -----------------------------------------------------------------------
611    // Test: Multi/Pattern without aggregator → error
612    // -----------------------------------------------------------------------
613    #[tokio::test]
614    async fn multi_without_aggregator_is_error() {
615        let schema = status_schema();
616        let store = make_store_with_rows(&schema, vec![]).await;
617        let registry = registry_from_store("items", store, schema);
618
619        let record = AliasRecord {
620            name: "multi_alias".into(),
621            sources: SourceSpec::Multi(vec!["items".into(), "other".into()]),
622            aggregator: None,
623            filter: r#"{"type":"eq","field":"status","value":"open"}"#.into(),
624            default_limit: None,
625            description: None,
626            params_schema: None,
627            fields: None,
628            order_by: None,
629            scope: None,
630        };
631
632        let err = execute_alias_run(&registry, record, None, None, None, None, None, None)
633            .await
634            .expect_err("should fail");
635
636        let msg = err.to_string();
637        assert!(
638            msg.contains("Multi/Pattern source aliases require an aggregator"),
639            "unexpected error: {msg}"
640        );
641    }
642}