yeti_types/inventory.rs
1//! Process-wide deployment inventory.
2//!
3//! Captures the full discoverable shape of a yeti
4//! deployment (databases, apps, tables, interfaces, functions) in a
5//! single deterministic-order struct so MCP-attached agents can ask
6//! one question and get the whole map.
7//!
8//! Same one-shot pattern as [`crate::app_snapshot`]: yeti-host writes
9//! once at startup-complete, tooling crates (yeti-mcp, audit, etc.)
10//! read lock-free thereafter. Yeti does not hot-add apps post-boot,
11//! so a `OnceLock` is sufficient — swap to `ArcSwap` if/when that
12//! changes.
13//!
14//! The shape is intentionally agent-friendly:
15//! - sorted vectors (not maps) so JSON serialization is stable;
16//! - effective transport flags resolved at build time (the `@export`
17//! default-true rule already applied), so consumers don't have to
18//! re-derive availability;
19//! - cross-cutting indexes (`tables`, `interfaces`, `functions`)
20//! materialized rather than implied so agents don't have to walk
21//! the app tree.
22
23use std::sync::{Arc, OnceLock};
24
25use serde::{Deserialize, Serialize};
26
27/// The full deployment inventory. Populated once by yeti-host at
28/// startup-complete via [`set_deployment_inventory`].
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DeploymentInventory {
31 /// Hash identifying this deployment (currently hardcoded
32 /// `"local"` until Fabric multi-deployment hosting plumbs a real
33 /// value through; see the `TransactionLog` convention).
34 pub deployment_hash: String,
35 /// Distinct databases referenced by any registered table, sorted by name.
36 pub databases: Vec<DatabaseEntry>,
37 /// Registered applications, sorted by id.
38 pub apps: Vec<AppEntry>,
39 /// Every `@table` across every app, sorted by `(app_id, name)`.
40 pub tables: Vec<TableEntry>,
41 /// Every interface surface (table × transport, custom function),
42 /// sorted by `logical_name`.
43 pub interfaces: Vec<InterfaceEntry>,
44 /// Every custom Resource (`resources/*.rs`), sorted by `(app_id, name)`.
45 pub functions: Vec<FunctionEntry>,
46}
47
48/// One database namespace and the tables it hosts.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct DatabaseEntry {
51 /// Database name (e.g. `"yeti-auth"`, `"demo-basic"`).
52 pub name: String,
53 /// Sorted list of table names backed by this database.
54 pub table_names: Vec<String>,
55}
56
57/// One registered application.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AppEntry {
60 /// Application id (URL slug).
61 pub id: String,
62 /// Human-readable name from `[package.metadata.app] name`.
63 pub name: String,
64 /// Mount-point prefix (`/yeti-auth`, etc.). Empty for the root app.
65 pub route_prefix: String,
66 /// App version from `[package] version` in Cargo.toml (string
67 /// form; currently "latest" for most apps until proper versioning).
68 pub version: String,
69 /// `true` when `[package.metadata.app] plugin = true` — the app
70 /// provides shared services rather than user-facing endpoints.
71 pub is_plugin: bool,
72 /// Sorted list of table names this app declares.
73 pub table_names: Vec<String>,
74 /// Sorted list of custom resource function names this app declares
75 /// (from `resources/*.rs`).
76 pub function_names: Vec<String>,
77}
78
79/// One `@table` declaration, with effective (post-default-resolution)
80/// transport flags.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct TableEntry {
83 /// Table type name (`User`, `Role`, `DemoItem`).
84 pub name: String,
85 /// Database namespace this table lives in.
86 pub database: String,
87 /// Owning application id.
88 pub app_id: String,
89 /// Field summaries (name, type, required, indexed).
90 pub fields: Vec<FieldSummary>,
91 /// Effective transport availability — `@export` default-true
92 /// already applied. A flag is `true` iff the table is reachable
93 /// on that transport at this deployment.
94 pub transports: TransportFlags,
95}
96
97/// One field of a `TableEntry`.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct FieldSummary {
100 /// Field name.
101 pub name: String,
102 /// GraphQL type as declared (`String`, `Int`, `Float`, `MyEnum`,
103 /// possibly with `!` non-null marker stripped).
104 pub type_name: String,
105 /// `true` iff the field is non-null in the schema (had a trailing `!`).
106 pub required: bool,
107 /// `true` iff the field carries an `@indexed` (or `@primary` /
108 /// `@vector`) directive.
109 pub indexed: bool,
110}
111
112/// Per-transport availability flags. All seven transports default to
113/// `true` whenever `@export` is present on a table (no args means
114/// "exported everywhere"); explicit `false` args opt out.
115#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
116#[allow(clippy::struct_excessive_bools)]
117pub struct TransportFlags {
118 /// REST CRUD over HTTP/HTTPS.
119 pub rest: bool,
120 /// GraphQL field on the per-app `/graphql` endpoint.
121 pub graphql: bool,
122 /// WebSocket subscription.
123 pub ws: bool,
124 /// Server-Sent Events streaming.
125 pub sse: bool,
126 /// MQTT publish/subscribe topic.
127 pub mqtt: bool,
128 /// MCP tool surface (`{table}_{op}`).
129 pub mcp: bool,
130 /// gRPC method (config-only until the bridge ships).
131 pub grpc: bool,
132}
133
134impl TransportFlags {
135 /// Project the canonical [`TransportSet`](crate::transport::TransportSet)
136 /// into the serialized per-transport bool shape. The one conversion
137 /// from the canonical set to this wire representation.
138 #[must_use]
139 pub const fn from_set(set: crate::transport::TransportSet) -> Self {
140 use crate::transport::Transport;
141 Self {
142 rest: set.contains(Transport::Rest),
143 graphql: set.contains(Transport::GraphQl),
144 ws: set.contains(Transport::Ws),
145 sse: set.contains(Transport::Sse),
146 mqtt: set.contains(Transport::Mqtt),
147 mcp: set.contains(Transport::Mcp),
148 grpc: set.contains(Transport::Grpc),
149 }
150 }
151}
152
153/// Kind discriminator for `InterfaceEntry`.
154#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum InterfaceKind {
157 /// Auto-generated CRUD surface for a `@table`.
158 Table,
159 /// Custom Resource implementation (`resources/*.rs`).
160 CustomFunction,
161}
162
163/// One reachable surface (table on a transport, or a custom function).
164///
165/// Materializes the cross product of (table, transport) plus one entry
166/// per custom function so agents can search by logical name without
167/// re-deriving URLs.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct InterfaceEntry {
170 /// Stable lookup key. For tables: `{app_id}.{Table}`. For custom
171 /// functions: `{app_id}.{function_name}`.
172 pub logical_name: String,
173 /// Whether this surface comes from a `@table` or a custom function.
174 pub kind: InterfaceKind,
175 /// Source identifier — same shape as `logical_name` today; kept
176 /// separate so future tooling can decorate `logical_name`
177 /// (e.g. with operation suffixes) without losing the back-pointer.
178 pub source: String,
179 /// REST endpoint when available (`/{app}/{Table}`).
180 pub rest_url: Option<String>,
181 /// GraphQL field path (`/{app}/graphql#Query.{Table}`).
182 pub graphql_field: Option<String>,
183 /// WebSocket URL (`wss://host/{app}/{Table}`); host portion is a
184 /// placeholder template `{host}` since the inventory builder
185 /// doesn't know the listener address at build time.
186 pub ws_url: Option<String>,
187 /// SSE URL (`/{app}/{Table}?stream=sse`).
188 pub sse_url: Option<String>,
189 /// MQTT topic (`{app_id}/{Table}`).
190 pub mqtt_topic: Option<String>,
191 /// MCP tool name (`{table_lower}_get`); arbitrary one of the six
192 /// CRUD tools generated per table — the canonical "discovery
193 /// breadcrumb" entry. Agents should call `tools/list` for the full set.
194 pub mcp_tool: Option<String>,
195 /// gRPC method path stub. Best-effort: `/{app}.{Table}/Get` style.
196 /// The actual gRPC bridge isn't implemented yet — no dispatcher
197 /// ships and the server-wide `grpc.enabled` config flag is
198 /// ignored, with a startup `tracing::warn!` on the legacy key.
199 /// This field documents the planned shape so
200 /// inventory consumers can render a stable column.
201 pub grpc_method: Option<String>,
202}
203
204/// One custom Resource implementation surfaced by an app.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct FunctionEntry {
207 /// Function/resource name as registered.
208 pub name: String,
209 /// Owning application id.
210 pub app_id: String,
211 /// REST endpoint URL.
212 pub rest_url: String,
213 /// HTTP verbs the function answers. Determined per-function at
214 /// registration time. Inventory currently emits an empty list — the
215 /// `AutoRouter` doesn't expose per-resource verb tables. Agents
216 /// should treat empty as "unknown; try GET first."
217 pub verbs: Vec<String>,
218 /// Optional human description.
219 pub description: Option<String>,
220}
221
222static DEPLOYMENT_INVENTORY: OnceLock<Arc<DeploymentInventory>> = OnceLock::new();
223
224/// Install the process-wide inventory snapshot. Called once by
225/// yeti-host after the startup phase completes; subsequent calls
226/// silently no-op (`OnceLock::set` fails after the first init).
227pub fn set_deployment_inventory(inv: DeploymentInventory) {
228 let _ = DEPLOYMENT_INVENTORY.set(Arc::new(inv));
229}
230
231/// Borrow the installed inventory, if any. Returns `None` when called
232/// before [`set_deployment_inventory`] — typically because the reader
233/// fired before startup completed. Tool handlers should surface this
234/// as a transient error.
235#[must_use]
236pub fn get_deployment_inventory() -> Option<Arc<DeploymentInventory>> {
237 DEPLOYMENT_INVENTORY.get().map(Arc::clone)
238}