Skip to main content

bynk_project/
schema_registry.rs

1//! Events track, slice 3c (#980): the cross-build schema registry's document
2//! shape and (de)serialisation.
3//!
4//! P4.0 (#1113, [DECISION A]): this module used to also hold `reconcile` —
5//! the `bynk_check`-coupled half that diffs a project's live event shapes
6//! against this document (it reads `UnitTable`, which stays in `bynk-emit`
7//! pending P4.1's `bynk-check` entry point). Only the pure, disk-free
8//! document shape and its parse/serialize moved here; `reconcile` and its
9//! helpers (`snapshot`, `canon_type`, `Reconciled`, …) stay in `bynk-emit`'s
10//! own `project/schema_registry.rs`, and reach `SchemaRegistry`'s otherwise-
11//! private `version`/`events` only through [`SchemaRegistry::new`],
12//! [`SchemaRegistry::get`], and [`SchemaRegistry::insert`] — never raw field
13//! access across the crate boundary.
14//!
15//! #1078: this module touches no disk. `parse`/`serialize` are pure —
16//! `bynk.schema.lock`'s content comes in through
17//! `CompileOptions::schema_registry`'s `SchemaLock::On { existing }` and goes
18//! out through `ProjectOutput::schema_lock`; `bynk-driver`'s `schema_lock`
19//! module owns the actual read/atomic-write. `parse` takes a
20//! `project_root: &Path` (#1085 review) purely to name the file in a
21//! corruption message — never for I/O.
22
23use std::collections::BTreeMap;
24use std::path::Path;
25
26use serde::{Deserialize, Serialize};
27
28pub fn lock_version() -> u32 {
29    1
30}
31
32#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
33pub struct SchemaRegistry {
34    // No serde `default`: a registry with no `version` is not a fresh
35    // project, it is corruption (a truncated write), and must fail the read
36    // rather than silently re-baseline every event's history — the same
37    // argument `bynk.deploy.lock`'s `DeployLock` makes for the identical
38    // field.
39    version: u32,
40    #[serde(default)]
41    events: BTreeMap<String, EventEntry>,
42}
43
44impl SchemaRegistry {
45    /// A fresh, empty registry at the current lock version — what
46    /// `bynk-emit`'s `reconcile` starts from and fills in per-event via
47    /// [`Self::insert`] as it walks the project's units. [DECISION A]: kept
48    /// private-fielded so `version`/`events` stay encapsulated across the
49    /// `bynk-emit` ↔ `bynk-project` boundary the way they always were
50    /// in-crate.
51    pub fn new() -> Self {
52        SchemaRegistry {
53            version: lock_version(),
54            events: BTreeMap::new(),
55        }
56    }
57
58    /// This registry's entry for `key` (`<unit>.<EventName>`), when one
59    /// exists — what `reconcile` diffs a unit's live event shape against.
60    pub fn get(&self, key: &str) -> Option<&EventEntry> {
61        self.events.get(key)
62    }
63
64    /// Record `entry` under `key`, overwriting any prior entry — what
65    /// `reconcile` calls once per event as it rebuilds the updated document.
66    pub fn insert(&mut self, key: String, entry: EventEntry) {
67        self.events.insert(key, entry);
68    }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct EventEntry {
73    pub schema: i64,
74    pub fields: Vec<FieldShape>,
75}
76
77/// A shallow, per-field snapshot of an event's current shape — deliberately
78/// **not** `bynk-check/src/contract.rs`'s `canon_named_in`: that renders a
79/// field as `name: type` with no signal for default-presence, so an additive
80/// change (new field, has a default) and a breaking one (new field, no
81/// default) perturb it identically. This snapshot exists to tell those two
82/// apart, one field at a time.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84pub struct FieldShape {
85    pub name: String,
86    #[serde(rename = "type")]
87    pub ty: String,
88    pub default: bool,
89}
90
91/// Parse `bynk.schema.lock`'s content. `existing: None` means the project
92/// has no lock file yet — a fresh project's first reconciliation, baselined
93/// rather than compared, exactly as a missing file always meant back when
94/// this function read the file itself. It must mean *verified absent*, never
95/// "content unavailable" for some other reason — see `SchemaLock::On`'s doc
96/// comment, which states that invariant on the type the caller constructs.
97///
98/// `Some(text)` that is empty/unparseable/a different lock version is
99/// corruption, not a fresh project, and fails hard (`ledger.rs`'s own
100/// argument for the identical case) — never silently re-baselines.
101///
102/// `project_root` is used only to name the file in an error message — #1078
103/// made this function disk-free (and so path-blind) for the content itself;
104/// #1085 review restored the location context a corruption diagnostic lost
105/// as a result, without reintroducing any `fs`/`Path` I/O here.
106pub fn parse(existing: Option<&str>, project_root: &Path) -> Result<SchemaRegistry, String> {
107    let Some(text) = existing else {
108        return Ok(SchemaRegistry::new());
109    };
110    let path = project_root.join("bynk.schema.lock");
111    if text.trim().is_empty() {
112        return Err(format!(
113            "schema registry `{}` is empty or truncated (corrupt); refusing \
114             to treat it as a fresh project — restore it from version control",
115            path.display()
116        ));
117    }
118    let reg: SchemaRegistry = toml::from_str(text).map_err(|e| {
119        format!(
120            "schema registry `{}` is corrupt ({e}) — restore it from version control",
121            path.display()
122        )
123    })?;
124    if reg.version != lock_version() {
125        return Err(format!(
126            "unsupported schema registry version {} (`{}`)",
127            reg.version,
128            path.display()
129        ));
130    }
131    Ok(reg)
132}
133
134/// Serialize a reconciled registry to `bynk.schema.lock`'s TOML form —
135/// what `bynk-driver` writes to disk, atomically, only on a fully clean
136/// build (`compile_project`'s own gate). No disk access here; see this
137/// module's doc comment for why. Cannot fail for this data shape (string
138/// keys, no floats) — `toml::to_string_pretty` only errors on inputs this
139/// struct never produces.
140pub fn serialize(reg: &SchemaRegistry) -> String {
141    toml::to_string_pretty(reg).expect("SchemaRegistry always serializes")
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn shape(name: &str, ty: &str, default: bool) -> FieldShape {
149        FieldShape {
150            name: name.to_string(),
151            ty: ty.to_string(),
152            default,
153        }
154    }
155
156    fn test_root() -> &'static Path {
157        Path::new("/project")
158    }
159
160    #[test]
161    fn new_registry_is_empty_at_the_current_lock_version() {
162        let reg = SchemaRegistry::new();
163        assert_eq!(reg.version, lock_version());
164        assert!(reg.events.is_empty());
165    }
166
167    #[test]
168    fn insert_then_get_round_trips_an_entry() {
169        let mut reg = SchemaRegistry::new();
170        let entry = EventEntry {
171            schema: 2,
172            fields: vec![shape("orderId", "String", false)],
173        };
174        reg.insert("commerce.order.PaymentConfirmed".to_string(), entry.clone());
175        assert_eq!(reg.get("commerce.order.PaymentConfirmed"), Some(&entry));
176        assert_eq!(reg.get("no.such.key"), None);
177    }
178
179    #[test]
180    fn parse_of_absent_content_is_an_empty_registry() {
181        let reg = parse(None, test_root()).unwrap();
182        assert!(reg.events.is_empty());
183    }
184
185    #[test]
186    fn parse_round_trips_serialized_content() {
187        let mut reg = SchemaRegistry::new();
188        reg.insert(
189            "commerce.order.PaymentConfirmed".to_string(),
190            EventEntry {
191                schema: 2,
192                fields: vec![shape("orderId", "String", false)],
193            },
194        );
195        let body = serialize(&reg);
196        assert_eq!(parse(Some(&body), test_root()).unwrap(), reg);
197    }
198
199    #[test]
200    fn a_truncated_file_is_corruption_not_a_fresh_project() {
201        let err = parse(Some("   \n"), test_root()).unwrap_err();
202        assert!(err.contains("restore it from version control"));
203        // Not a full path match (`\` vs `/` makes that Windows-fragile) —
204        // just proving the filename made it into the message at all.
205        assert!(err.contains("bynk.schema.lock"));
206    }
207
208    #[test]
209    fn an_unparseable_file_is_corruption() {
210        let err = parse(Some("not valid toml {{{"), test_root()).unwrap_err();
211        assert!(err.contains("corrupt"));
212        assert!(err.contains("bynk.schema.lock"));
213    }
214
215    #[test]
216    fn an_unsupported_lock_version_is_corruption() {
217        let err = parse(Some("version = 99\n"), test_root()).unwrap_err();
218        assert!(err.contains("unsupported schema registry version 99"));
219    }
220}