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(®);
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}