Skip to main content

crdt_codegen/
schema.rs

1use serde::Deserialize;
2
3/// Top-level schema file structure parsed from `crdt-schema.toml`.
4#[derive(Debug, Clone, Deserialize)]
5pub struct SchemaFile {
6    /// Global configuration.
7    pub config: SchemaConfig,
8    /// Entity definitions.
9    #[serde(rename = "entity")]
10    pub entities: Vec<Entity>,
11}
12
13/// Global code-generation configuration.
14#[derive(Debug, Clone, Deserialize)]
15pub struct SchemaConfig {
16    /// Output directory relative to the project root.
17    pub output: String,
18    /// Event sourcing configuration (optional).
19    pub events: Option<EventsConfig>,
20    /// Sync/delta configuration (optional).
21    pub sync: Option<SyncConfig>,
22}
23
24/// Configuration for event sourcing code generation.
25#[derive(Debug, Clone, Deserialize)]
26pub struct EventsConfig {
27    /// Whether to generate event types and event sourcing helpers.
28    #[serde(default)]
29    pub enabled: bool,
30    /// Number of events before a snapshot is recommended.
31    #[serde(default = "default_snapshot_threshold")]
32    pub snapshot_threshold: u64,
33}
34
35fn default_snapshot_threshold() -> u64 {
36    100
37}
38
39/// Configuration for delta sync code generation.
40#[derive(Debug, Clone, Deserialize)]
41pub struct SyncConfig {
42    /// Whether to generate delta sync helpers for entities with CRDT fields.
43    #[serde(default)]
44    pub enabled: bool,
45}
46
47/// A single entity definition with one or more versioned schemas.
48#[derive(Debug, Clone, Deserialize)]
49pub struct Entity {
50    /// Entity name in PascalCase (e.g., `"Task"`).
51    pub name: String,
52    /// Storage namespace / table name (e.g., `"tasks"`).
53    pub table: String,
54    /// Ordered list of schema versions. Must start at 1 and be contiguous.
55    pub versions: Vec<EntityVersion>,
56}
57
58/// A specific version of an entity's schema.
59#[derive(Debug, Clone, Deserialize)]
60pub struct EntityVersion {
61    /// Version number (1, 2, 3, ...).
62    pub version: u32,
63    /// Fields in this version.
64    pub fields: Vec<Field>,
65}
66
67/// A single field within an entity version.
68#[derive(Debug, Clone, Deserialize)]
69pub struct Field {
70    /// Field name in snake_case.
71    pub name: String,
72    /// Rust type as a string (e.g., `"String"`, `"Option<u8>"`, `"Vec<String>"`).
73    #[serde(rename = "type")]
74    pub field_type: String,
75    /// Default value expression (Rust literal). Required for fields added in
76    /// later versions so that automatic migration can fill them in.
77    pub default: Option<String>,
78    /// CRDT type wrapping this field (e.g., `"LWWRegister"`, `"GCounter"`, `"ORSet"`).
79    ///
80    /// When set, the generated Rust type becomes `CrdtType<field_type>` (or just
81    /// `CrdtType` for counter types). Migration defaults are auto-generated.
82    pub crdt: Option<String>,
83    /// Relation to another entity (e.g., `"Project"` means this field is a key
84    /// referencing a Project entity). Generates typed lookup helpers.
85    pub relation: Option<String>,
86}
87
88/// Supported CRDT type names and their properties.
89pub const SUPPORTED_CRDTS: &[CrdtInfo] = &[
90    CrdtInfo {
91        name: "GCounter",
92        is_generic: false,
93        default_expr: "GCounter::new(\"_migrated\")",
94    },
95    CrdtInfo {
96        name: "PNCounter",
97        is_generic: false,
98        default_expr: "PNCounter::new(\"_migrated\")",
99    },
100    CrdtInfo {
101        name: "LWWRegister",
102        is_generic: true,
103        default_expr: "LWWRegister::with_timestamp(\"_migrated\", Default::default(), 0)",
104    },
105    CrdtInfo {
106        name: "MVRegister",
107        is_generic: true,
108        default_expr: "MVRegister::new(\"_migrated\")",
109    },
110    CrdtInfo {
111        name: "GSet",
112        is_generic: true,
113        default_expr: "GSet::new()",
114    },
115    CrdtInfo {
116        name: "TwoPSet",
117        is_generic: true,
118        default_expr: "TwoPSet::new()",
119    },
120    CrdtInfo {
121        name: "ORSet",
122        is_generic: true,
123        default_expr: "ORSet::new(\"_migrated\")",
124    },
125];
126
127/// Metadata about a supported CRDT type.
128pub struct CrdtInfo {
129    /// CRDT type name (e.g., `"GCounter"`).
130    pub name: &'static str,
131    /// Whether the CRDT is generic over a type parameter (e.g., `LWWRegister<T>`).
132    pub is_generic: bool,
133    /// Default expression for migration (creates an empty/default instance).
134    pub default_expr: &'static str,
135}
136
137/// Look up a CRDT info by name.
138pub fn lookup_crdt(name: &str) -> Option<&'static CrdtInfo> {
139    SUPPORTED_CRDTS.iter().find(|c| c.name == name)
140}
141
142/// CRDTs that implement `DeltaCrdt` and their delta type names.
143pub const DELTA_CRDTS: &[(&str, &str)] = &[
144    ("GCounter", "GCounterDelta"),
145    ("PNCounter", "PNCounterDelta"),
146    ("ORSet", "ORSetDelta"),
147];
148
149/// Look up the delta type name for a CRDT, if it supports `DeltaCrdt`.
150pub fn lookup_delta_type(crdt_name: &str) -> Option<&'static str> {
151    DELTA_CRDTS
152        .iter()
153        .find(|(name, _)| *name == crdt_name)
154        .map(|(_, delta)| *delta)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn parse_minimal_schema() {
163        let toml = r#"
164[config]
165output = "src/generated"
166
167[[entity]]
168name = "Task"
169table = "tasks"
170
171[[entity.versions]]
172version = 1
173fields = [
174    { name = "title", type = "String" },
175    { name = "done", type = "bool" },
176]
177"#;
178        let schema: SchemaFile = toml::from_str(toml).unwrap();
179        assert_eq!(schema.config.output, "src/generated");
180        assert_eq!(schema.entities.len(), 1);
181        assert_eq!(schema.entities[0].name, "Task");
182        assert_eq!(schema.entities[0].table, "tasks");
183        assert_eq!(schema.entities[0].versions[0].fields.len(), 2);
184    }
185
186    #[test]
187    fn parse_crdt_and_relation_fields() {
188        let toml = r#"
189[config]
190output = "out"
191
192[[entity]]
193name = "Task"
194table = "tasks"
195
196[[entity.versions]]
197version = 1
198fields = [
199    { name = "title", type = "String", crdt = "LWWRegister" },
200    { name = "views", type = "u64", crdt = "GCounter" },
201    { name = "project_id", type = "String", relation = "Project" },
202]
203"#;
204        let schema: SchemaFile = toml::from_str(toml).unwrap();
205        let fields = &schema.entities[0].versions[0].fields;
206        assert_eq!(fields[0].crdt.as_deref(), Some("LWWRegister"));
207        assert_eq!(fields[1].crdt.as_deref(), Some("GCounter"));
208        assert_eq!(fields[2].relation.as_deref(), Some("Project"));
209    }
210
211    #[test]
212    fn parse_events_and_sync_config() {
213        let toml = r#"
214[config]
215output = "src/persistence"
216
217[config.events]
218enabled = true
219snapshot_threshold = 200
220
221[config.sync]
222enabled = true
223
224[[entity]]
225name = "Task"
226table = "tasks"
227
228[[entity.versions]]
229version = 1
230fields = [
231    { name = "title", type = "String" },
232]
233"#;
234        let schema: SchemaFile = toml::from_str(toml).unwrap();
235        let events = schema.config.events.unwrap();
236        assert!(events.enabled);
237        assert_eq!(events.snapshot_threshold, 200);
238        let sync = schema.config.sync.unwrap();
239        assert!(sync.enabled);
240    }
241
242    #[test]
243    fn parse_config_without_events_sync() {
244        let toml = r#"
245[config]
246output = "out"
247
248[[entity]]
249name = "Task"
250table = "tasks"
251
252[[entity.versions]]
253version = 1
254fields = [
255    { name = "title", type = "String" },
256]
257"#;
258        let schema: SchemaFile = toml::from_str(toml).unwrap();
259        assert!(schema.config.events.is_none());
260        assert!(schema.config.sync.is_none());
261    }
262
263    #[test]
264    fn lookup_delta_type_works() {
265        assert_eq!(lookup_delta_type("GCounter"), Some("GCounterDelta"));
266        assert_eq!(lookup_delta_type("PNCounter"), Some("PNCounterDelta"));
267        assert_eq!(lookup_delta_type("ORSet"), Some("ORSetDelta"));
268        assert_eq!(lookup_delta_type("LWWRegister"), None);
269        assert_eq!(lookup_delta_type("GSet"), None);
270    }
271
272    #[test]
273    fn parse_multi_version_schema() {
274        let toml = r#"
275[config]
276output = "out"
277
278[[entity]]
279name = "Sensor"
280table = "sensors"
281
282[[entity.versions]]
283version = 1
284fields = [
285    { name = "device_id", type = "String" },
286    { name = "temperature", type = "f32" },
287]
288
289[[entity.versions]]
290version = 2
291fields = [
292    { name = "device_id", type = "String" },
293    { name = "temperature", type = "f32" },
294    { name = "humidity", type = "Option<f32>", default = "None" },
295]
296"#;
297        let schema: SchemaFile = toml::from_str(toml).unwrap();
298        let sensor = &schema.entities[0];
299        assert_eq!(sensor.versions.len(), 2);
300        assert_eq!(sensor.versions[1].fields[2].name, "humidity");
301        assert_eq!(
302            sensor.versions[1].fields[2].default.as_deref(),
303            Some("None")
304        );
305    }
306}