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. Empty when the index hashes on a plain
53 /// attribute (a tenant id, say) and only its sort key is templated: the
54 /// index is still listed so the sort key template reaches every reader.
55 pub pk_template: String,
56 /// GSI sort key template (if the index has a sort key)
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub sk_template: Option<String>,
59}
60
61impl DataModel {
62 /// Generate a compact summary for MCP instructions.
63 ///
64 /// Returns entity names with their GSI participation, truncated to `limit`.
65 /// If `limit` is 0, returns `None` (summary suppressed).
66 pub fn instructions_summary(&self, limit: usize) -> Option<String> {
67 if limit == 0 {
68 return None;
69 }
70
71 let entity_count = self.entities.len();
72 let shown = self.entities.iter().take(limit);
73
74 let entity_parts: Vec<String> = shown
75 .map(|e| {
76 if e.gsi_mappings.is_empty() {
77 e.name.clone()
78 } else {
79 let gsis: Vec<&str> = e
80 .gsi_mappings
81 .iter()
82 .map(|g| g.index_name.as_str())
83 .collect();
84 format!("{} ({})", e.name, gsis.join(", "))
85 }
86 })
87 .collect();
88
89 let mut summary = format!(
90 "## Data model\n\n\
91 Schema: {} ({} entities, type attribute: \"{}\")\n\
92 Entities: {}",
93 self.schema_format,
94 entity_count,
95 self.type_attribute,
96 entity_parts.join(", "),
97 );
98
99 if entity_count > limit {
100 summary.push_str(&format!("...and {} more", entity_count - limit));
101 }
102
103 summary.push_str(
104 "\n\nCall get_database_info for full entity definitions with key templates.\n\
105 Note: Data model definitions describe the intended schema but are not enforced. \
106 Actual database contents may differ.",
107 );
108
109 Some(summary)
110 }
111}