Skip to main content

yeti_types/schema/
table.rs

1//! `TableDefinition`, its builder, and GraphQL-to-JSON type mapping.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::backend::{BackendType, ConsistencyMode};
6use crate::transport::{Transport, TransportSet};
7use crate::types::TableName;
8
9use super::access::{AccessConfig, PublicAccess};
10use super::audit::AuditConfig;
11use super::diff::SchemaDiff;
12use super::distribute::DistributeConfig;
13use super::field::{CompositeIndexDef, FieldDefinition, HnswConfig, IndexConfig};
14use super::source::SourceConfig;
15use super::store::StoreConfig;
16
17// ============================================================================
18// TableDefinition
19// ============================================================================
20
21/// Represents a table definition parsed from a GraphQL schema.
22#[derive(Debug, Clone)]
23#[expect(
24    clippy::struct_excessive_bools,
25    reason = "schema-parser output; each bool maps 1:1 to an interface flag from `@export(rest, ws, sse, mqtt, mcp, grpc, graphql)`. Bitflags would force a parallel Interface enum already covered by the directive vocabulary, and obscure the table-definition reader's at-a-glance view of which interfaces a table exposes."
26)]
27pub struct TableDefinition {
28    /// GraphQL type name
29    pub name: String,
30    /// Database namespace (default: "data")
31    pub database: String,
32    /// Physical table name (default: same as `name`)
33    pub table_name: TableName,
34    /// Storage backend type (default: `RocksDb`)
35    pub storage: BackendType,
36    /// List of field definitions
37    pub fields: Vec<FieldDefinition>,
38    /// Name of the primary key field
39    pub primary_key: String,
40    /// List of indexed attribute names
41    pub indexed: Vec<String>,
42    /// Whether this table is exported as a REST endpoint
43    pub exported: bool,
44    /// URL path segment from `@export(path: "/v1/posts")`.
45    /// Stored without leading/trailing slashes (the loader normalizes
46    /// during parse). Replaces the type-name default segment in
47    /// route registration. `None` falls back to the lowercased type
48    /// name. Replaces the older `@export(name:)` arg.
49    pub custom_path: Option<String>,
50    /// Whether REST interface is enabled
51    pub rest_enabled: bool,
52    /// Whether GraphQL interface is enabled
53    pub graphql_enabled: bool,
54    /// Whether WebSocket interface is enabled
55    pub ws_enabled: bool,
56    /// Whether SSE interface is enabled
57    pub sse_enabled: bool,
58    /// Whether MQTT interface is enabled
59    pub mqtt_enabled: bool,
60    /// Whether MCP interface is enabled
61    pub mcp_enabled: bool,
62    /// Whether gRPC interface is enabled
63    pub grpc_enabled: bool,
64    /// Operations declared publicly accessible. Now sourced
65    /// from `@access(public: [...])`; populated identically from
66    /// `AccessConfig.public` so existing runtime consumers
67    /// (`yeti-table` constructors, `discovery` JSON) don't have to
68    /// dereference an `Option` on the hot path. Future hardening can
69    /// fold this back through `access`.
70    pub public_operations: HashSet<PublicAccess>,
71    /// Authorization config from `@access` directive.
72    /// `None` = no `@access` block in the schema; the app's auth
73    /// pipeline owns access decisions. When set, `access.public`
74    /// matches `public_operations` and `access.roles` carries the
75    /// per-op RBAC matrix.
76    pub access: Option<AccessConfig>,
77    /// Cache expiration in seconds from @table(expiration: N)
78    pub expiration: Option<u64>,
79    /// Composite index definitions
80    pub composite_indexes: Vec<CompositeIndexDef>,
81    /// Distribution topology from @distribute directive
82    pub distribute: Option<DistributeConfig>,
83    /// Storage-engine config from `@store` directive.
84    /// None = platform defaults: `BackendType::Disk` + WAL on.
85    /// When set, `store.durability`
86    /// overrides the system-database `WAL_REQUIRED_DATABASES` policy.
87    pub store: Option<StoreConfig>,
88    /// Origin source config from `@source` directive.
89    /// None = data lives only in this table. When set, one of three
90    /// arms drives populator behavior: URL pull, function call, or
91    /// cross-table sync. Runtime arms land in follow-on commits;
92    /// the directive surface ships first.
93    pub source: Option<SourceConfig>,
94    /// Audit configuration from @audit directive
95    pub audit: Option<AuditConfig>,
96    /// Per-field CRDT type declarations (`field_name` -> `crdt_type`)
97    pub crdt_fields: HashMap<String, String>,
98}
99
100impl Default for TableDefinition {
101    fn default() -> Self {
102        Self {
103            name: String::new(),
104            database: "data".to_owned(),
105            table_name: TableName::from(""),
106            storage: BackendType::Disk,
107            fields: vec![],
108            primary_key: "id".to_owned(),
109            indexed: vec![],
110            exported: true,
111            custom_path: None,
112            rest_enabled: true,
113            graphql_enabled: true,
114            ws_enabled: true,
115            sse_enabled: true,
116            mqtt_enabled: true,
117            mcp_enabled: true,
118            grpc_enabled: true,
119            public_operations: HashSet::new(),
120            access: None,
121            expiration: None,
122            composite_indexes: vec![],
123            distribute: None,
124            store: None,
125            source: None,
126            audit: None,
127            crdt_fields: HashMap::new(),
128        }
129    }
130}
131
132impl TableDefinition {
133    /// Returns the endpoint name: custom path if set, otherwise lowercase type name.
134    #[must_use]
135    pub fn endpoint_name(&self) -> String {
136        self.custom_path
137            .as_ref()
138            .map_or_else(|| self.name.to_lowercase(), std::clone::Clone::clone)
139    }
140
141    /// Convenience: get consistency mode from @distribute or None.
142    #[must_use]
143    pub fn consistency(&self) -> Option<ConsistencyMode> {
144        self.distribute.as_ref().and_then(|d| d.consistency)
145    }
146
147    /// Convenience: get residency from @distribute or None.
148    #[must_use]
149    pub fn residency(&self) -> Option<&str> {
150        self.distribute
151            .as_ref()
152            .and_then(|d| d.residency.as_deref())
153    }
154
155    /// Convenience: get replication factor from @distribute or None.
156    #[must_use]
157    pub fn replication_factor(&self) -> Option<u8> {
158        self.distribute.as_ref().and_then(|d| d.replication_factor)
159    }
160
161    /// Convenience: get sharding strategy from @distribute or None.
162    #[must_use]
163    pub fn sharding(&self) -> Option<&str> {
164        self.distribute.as_ref().and_then(|d| d.sharding.as_deref())
165    }
166
167    /// Whether this table has audit enabled.
168    #[must_use]
169    pub const fn is_audited(&self) -> bool {
170        self.audit.is_some()
171    }
172
173    /// Whether `transport` is enabled for this table. The single bridge
174    /// between the canonical [`Transport`] set and the readable per-
175    /// transport bool projection — consumers (inventory, dispatch) query
176    /// the canonical type, not seven scattered fields.
177    #[must_use]
178    pub const fn transport_enabled(&self, transport: Transport) -> bool {
179        match transport {
180            Transport::Rest => self.rest_enabled,
181            Transport::GraphQl => self.graphql_enabled,
182            Transport::Ws => self.ws_enabled,
183            Transport::Sse => self.sse_enabled,
184            Transport::Mqtt => self.mqtt_enabled,
185            Transport::Mcp => self.mcp_enabled,
186            Transport::Grpc => self.grpc_enabled,
187        }
188    }
189
190    /// The effective [`TransportSet`] this table exposes. Empty when the
191    /// table is not exported.
192    #[must_use]
193    pub fn transport_set(&self) -> TransportSet {
194        if !self.exported {
195            return TransportSet::NONE;
196        }
197        let mut set = TransportSet::NONE;
198        for t in Transport::ALL {
199            if self.transport_enabled(t) {
200                set.insert(t);
201            }
202        }
203        set
204    }
205
206    /// Compute a fingerprint of the schema-relevant parts of this table definition.
207    /// Used to detect schema changes between restarts. Only includes fields that
208    /// affect data layout, indexing, or defaults — not transport flags.
209    #[must_use]
210    pub fn schema_fingerprint(&self) -> String {
211        use std::collections::BTreeMap;
212        use std::fmt::Write;
213
214        let mut hasher_input = String::new();
215
216        // Sort fields by name for deterministic ordering
217        let mut fields: BTreeMap<&str, (&str, &str, bool, bool)> = BTreeMap::new();
218        for f in &self.fields {
219            let idx = match &f.index_config {
220                IndexConfig::None => "none",
221                IndexConfig::Standard => "standard",
222                IndexConfig::FullText => "fulltext",
223                IndexConfig::Vector { .. } => "vector",
224            };
225            let has_default = f.default_value.is_some();
226            fields.insert(&f.name, (&f.field_type, idx, f.is_primary, has_default));
227        }
228
229        for (name, (ftype, idx, primary, has_default)) in &fields {
230            let _ = writeln!(hasher_input, "{name}:{ftype}:{idx}:{primary}:{has_default}");
231        }
232
233        // Include primary key
234        let _ = writeln!(hasher_input, "pk:{}", self.primary_key);
235
236        // Simple hash — not cryptographic, just deterministic change detection
237        let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
238        for byte in hasher_input.bytes() {
239            hash ^= u64::from(byte);
240            hash = hash.wrapping_mul(0x0100_0000_01b3); // FNV-1a prime
241        }
242        format!("{hash:016x}")
243    }
244
245    /// Compute the diff between this schema and a stored fingerprint's field list.
246    /// Returns the fields to strip (removed), fields to inject defaults (new with @default),
247    /// and fields to build indexes for (gained @indexed).
248    #[must_use]
249    pub fn diff_against(&self, old_fields: &[String], old_indexed: &[String]) -> SchemaDiff {
250        let new_field_names: HashSet<&str> = self.fields.iter().map(|f| f.name.as_str()).collect();
251        let old_field_set: HashSet<&str> =
252            old_fields.iter().map(std::string::String::as_str).collect();
253        let old_indexed_set: HashSet<&str> = old_indexed
254            .iter()
255            .map(std::string::String::as_str)
256            .collect();
257
258        let mut diff = SchemaDiff::default();
259
260        // Fields removed from schema → strip from records
261        for old in old_fields {
262            if !new_field_names.contains(old.as_str()) && old != "id" && old != &self.primary_key {
263                diff.strip_fields.push(old.clone());
264            }
265        }
266
267        // Fields added with @default → inject into existing records
268        for field in &self.fields {
269            if !old_field_set.contains(field.name.as_str())
270                && let Some(ref default) = field.default_value
271            {
272                diff.inject_defaults
273                    .push((field.name.clone(), default.clone()));
274            }
275        }
276
277        // Fields that gained @indexed → build index entries
278        for field in &self.fields {
279            if field.index_config.is_indexed() && !old_indexed_set.contains(field.name.as_str()) {
280                diff.build_indexes
281                    .push((field.name.clone(), field.index_config.clone()));
282            }
283        }
284
285        // Fields that lost @indexed → drop index entries
286        for old_idx in old_indexed {
287            let still_indexed = self
288                .fields
289                .iter()
290                .any(|f| f.name == *old_idx && f.index_config.is_indexed());
291            if !still_indexed {
292                diff.drop_indexes.push(old_idx.clone());
293            }
294        }
295
296        diff
297    }
298
299    /// Create a new `TableDefinition` builder with the given name.
300    #[must_use]
301    pub fn builder(name: &str) -> TableDefinitionBuilder {
302        TableDefinitionBuilder::new(name)
303    }
304
305    /// Convert to JSON schema for core node table creation.
306    #[must_use]
307    pub fn to_json_schema(&self) -> serde_json::Value {
308        use serde_json::json;
309
310        let fields: Vec<_> = self
311            .fields
312            .iter()
313            .map(|f| {
314                json!({
315                    "name": f.name,
316                    "type": graphql_to_json_type(&f.field_type),
317                    "primaryKey": f.is_primary,
318                    "indexed": f.index_config.is_indexed(),
319                })
320            })
321            .collect();
322
323        json!({
324            "name": self.name,
325            "fields": fields,
326            "primaryKey": self.primary_key,
327        })
328    }
329}
330
331/// Map GraphQL types to JSON schema types via the canonical scalar
332/// registry. Unknown (custom object) types fall back to `"string"`.
333#[must_use]
334pub fn graphql_to_json_type(graphql_type: &str) -> &str {
335    crate::scalar::json_type(graphql_type)
336}
337
338/// Builder for creating `TableDefinition` instances with a fluent API.
339#[derive(Debug)]
340pub struct TableDefinitionBuilder {
341    def: TableDefinition,
342}
343
344impl TableDefinitionBuilder {
345    /// Create a new builder with the given table name.
346    #[must_use]
347    pub fn new(name: &str) -> Self {
348        Self {
349            def: TableDefinition {
350                name: name.to_owned(),
351                table_name: TableName::from(name),
352                ..Default::default()
353            },
354        }
355    }
356
357    /// Set the database namespace.
358    #[must_use]
359    pub fn database(mut self, database: &str) -> Self {
360        database.clone_into(&mut self.def.database);
361        self
362    }
363
364    /// Set the physical table name.
365    #[must_use]
366    pub fn table_name(mut self, table_name: &str) -> Self {
367        self.def.table_name = TableName::from(table_name);
368        self
369    }
370
371    /// Set the storage backend type.
372    #[must_use]
373    pub const fn storage(mut self, storage: BackendType) -> Self {
374        self.def.storage = storage;
375        self
376    }
377
378    /// Add a field to the table.
379    #[must_use]
380    pub fn field(mut self, name: &str, field_type: &str, is_primary: bool) -> Self {
381        let field = FieldDefinition {
382            name: name.to_owned(),
383            field_type: field_type.to_owned(),
384            is_primary,
385            ..Default::default()
386        };
387        if is_primary {
388            name.clone_into(&mut self.def.primary_key);
389        }
390        self.def.fields.push(field);
391        self
392    }
393
394    /// Add a field with a standard secondary index.
395    #[must_use]
396    pub fn indexed_field(mut self, name: &str, field_type: &str) -> Self {
397        let field = FieldDefinition {
398            name: name.to_owned(),
399            field_type: field_type.to_owned(),
400            is_primary: false,
401            index_config: IndexConfig::Standard,
402            ..Default::default()
403        };
404        self.def.indexed.push(name.to_owned());
405        self.def.fields.push(field);
406        self
407    }
408
409    /// Add a field with a vector (HNSW) index.
410    #[must_use]
411    pub fn vector_field(mut self, name: &str, hnsw_config: HnswConfig) -> Self {
412        let field = FieldDefinition {
413            name: name.to_owned(),
414            field_type: "Vector".to_owned(),
415            is_primary: false,
416            index_config: IndexConfig::Vector {
417                hnsw_config,
418                source: None,
419                model: None,
420            },
421            ..Default::default()
422        };
423        self.def.indexed.push(name.to_owned());
424        self.def.fields.push(field);
425        self
426    }
427
428    /// Set whether the table is exported.
429    #[must_use]
430    pub const fn exported(mut self, exported: bool) -> Self {
431        self.def.exported = exported;
432        self
433    }
434
435    /// Set custom endpoint path.
436    #[must_use]
437    pub fn custom_path(mut self, path: &str) -> Self {
438        self.def.custom_path = Some(path.to_owned());
439        self
440    }
441
442    /// Enable/disable REST interface.
443    #[must_use]
444    pub const fn rest_enabled(mut self, enabled: bool) -> Self {
445        self.def.rest_enabled = enabled;
446        self
447    }
448
449    /// Enable/disable GraphQL interface.
450    #[must_use]
451    pub const fn graphql_enabled(mut self, enabled: bool) -> Self {
452        self.def.graphql_enabled = enabled;
453        self
454    }
455
456    /// Enable/disable WebSocket interface.
457    #[must_use]
458    pub const fn ws_enabled(mut self, enabled: bool) -> Self {
459        self.def.ws_enabled = enabled;
460        self
461    }
462
463    /// Enable/disable SSE interface.
464    #[must_use]
465    pub const fn sse_enabled(mut self, enabled: bool) -> Self {
466        self.def.sse_enabled = enabled;
467        self
468    }
469
470    /// Enable/disable MCP interface.
471    #[must_use]
472    pub const fn mcp_enabled(mut self, enabled: bool) -> Self {
473        self.def.mcp_enabled = enabled;
474        self
475    }
476
477    /// Set cache expiration in seconds.
478    #[must_use]
479    pub const fn expiration(mut self, seconds: u64) -> Self {
480        self.def.expiration = Some(seconds);
481        self
482    }
483
484    /// Set consistency mode via distribute config.
485    #[must_use]
486    pub fn consistency(mut self, mode: ConsistencyMode) -> Self {
487        let dist = self.def.distribute.get_or_insert(DistributeConfig {
488            sharding: None,
489            shard_key: None,
490            shard_count: None,
491            residency: None,
492            replication_factor: None,
493            consistency: None,
494            replication: None,
495        });
496        dist.consistency = Some(mode);
497        self
498    }
499
500    /// Add a field with a full-text search index.
501    #[must_use]
502    pub fn fulltext_field(mut self, name: &str, field_type: &str) -> Self {
503        let field = FieldDefinition {
504            name: name.to_owned(),
505            field_type: field_type.to_owned(),
506            is_primary: false,
507            index_config: IndexConfig::FullText,
508            ..Default::default()
509        };
510        self.def.indexed.push(name.to_owned());
511        self.def.fields.push(field);
512        self
513    }
514
515    /// Add a composite index on multiple fields.
516    #[must_use]
517    pub fn composite_index(mut self, fields: &[&str]) -> Self {
518        let name = fields.join("_");
519        self.def.composite_indexes.push(CompositeIndexDef {
520            name,
521            fields: fields
522                .iter()
523                .map(std::string::ToString::to_string)
524                .collect(),
525        });
526        self
527    }
528
529    /// Build the `TableDefinition`.
530    #[must_use]
531    pub fn build(self) -> TableDefinition {
532        self.def
533    }
534}