dynoxide/schema/mod.rs
1//! Application-level data model definitions for MCP agent context.
2//!
3//! Parses schema files (e.g., OneTable) into a [`DataModel`] that can be
4//! exposed to MCP-connected agents via instructions and `get_database_info`.
5
6pub mod onetable;
7
8use serde::Serialize;
9
10/// Application-level data model parsed from a schema file (e.g., OneTable).
11/// Designed for agent consumption — serializes to JSON for MCP responses.
12#[derive(Debug, Clone, Serialize)]
13pub struct DataModel {
14 /// Schema format identifier, e.g. "onetable:1.1.0"
15 pub schema_format: String,
16 /// The attribute name used to discriminate entity types (e.g. "_type")
17 pub type_attribute: String,
18 /// Entity definitions with key templates and GSI mappings
19 pub entities: Vec<EntityDefinition>,
20}
21
22/// A single entity type within the data model.
23#[derive(Debug, Clone, Serialize)]
24pub struct EntityDefinition {
25 /// Entity name, e.g. "Account"
26 pub name: String,
27 /// Primary key partition template, e.g. "account#${id}"
28 pub pk_template: String,
29 /// Primary key sort template, e.g. "account#"
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub sk_template: Option<String>,
32 /// Entity-level type attribute override (usually same as DataModel.type_attribute)
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub type_attribute: Option<String>,
35 /// Which GSIs this entity participates in, with key templates
36 pub gsi_mappings: Vec<GsiMapping>,
37 /// Human-readable description from schema (if present)
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub description: Option<String>,
40}
41
42/// A GSI mapping for an entity, describing the key templates used.
43#[derive(Debug, Clone, Serialize)]
44pub struct GsiMapping {
45 /// DynamoDB index name resolved from the schema's index definitions.
46 ///
47 /// For OneTable schemas, this is resolved from the `name` field in the
48 /// index definition if present, otherwise falls back to the OneTable key
49 /// (e.g. "gs1"). Must match the name from CreateTable / describe_table
50 /// so agents can pass it directly to query's `index_name` parameter.
51 pub index_name: String,
52 /// GSI partition key template
53 pub pk_template: String,
54 /// GSI sort key template (if the index has a sort key)
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub sk_template: Option<String>,
57}
58
59impl DataModel {
60 /// Generate a compact summary for MCP instructions.
61 ///
62 /// Returns entity names with their GSI participation, truncated to `limit`.
63 /// If `limit` is 0, returns `None` (summary suppressed).
64 pub fn instructions_summary(&self, limit: usize) -> Option<String> {
65 if limit == 0 {
66 return None;
67 }
68
69 let entity_count = self.entities.len();
70 let shown = self.entities.iter().take(limit);
71
72 let entity_parts: Vec<String> = shown
73 .map(|e| {
74 if e.gsi_mappings.is_empty() {
75 e.name.clone()
76 } else {
77 let gsis: Vec<&str> = e
78 .gsi_mappings
79 .iter()
80 .map(|g| g.index_name.as_str())
81 .collect();
82 format!("{} ({})", e.name, gsis.join(", "))
83 }
84 })
85 .collect();
86
87 let mut summary = format!(
88 "## Data model\n\n\
89 Schema: {} ({} entities, type attribute: \"{}\")\n\
90 Entities: {}",
91 self.schema_format,
92 entity_count,
93 self.type_attribute,
94 entity_parts.join(", "),
95 );
96
97 if entity_count > limit {
98 summary.push_str(&format!("...and {} more", entity_count - limit));
99 }
100
101 summary.push_str(
102 "\n\nCall get_database_info for full entity definitions with key templates.\n\
103 Note: Data model definitions describe the intended schema but are not enforced. \
104 Actual database contents may differ.",
105 );
106
107 Some(summary)
108 }
109}