1use std::collections::BTreeMap;
38
39use bynk_syntax::ast::{
40 AgentDecl, EventDecl, Expr, ExprKind, MigrateDecl, MigrateItem, TypeBody, TypeRef,
41};
42use bynk_syntax::error::CompileError;
43use bynk_syntax::span::Span;
44
45use bynk_project::schema_registry::{
46 AgentEntry, AgentShape, EventEntry, FieldShape, SchemaRegistry, StoreShape,
47};
48
49use crate::symbols::UnitTable;
50
51pub fn parse_or_diagnose(
57 existing: Option<&str>,
58 project_root: &std::path::Path,
59) -> Result<SchemaRegistry, CompileError> {
60 bynk_project::schema_registry::parse(existing, project_root).map_err(|msg| {
61 CompileError::new("bynk.project.schema_registry_corrupt", Span::default(), msg)
62 })
63}
64
65fn snapshot(event: &EventDecl) -> Vec<FieldShape> {
72 let mut fields: Vec<FieldShape> = event
73 .body
74 .fields
75 .iter()
76 .map(|f| FieldShape {
77 name: f.name.name.clone(),
78 ty: canon_type(&f.type_ref),
79 default: f.init.is_some(),
80 })
81 .collect();
82 fields.sort_by(|a, b| a.name.cmp(&b.name));
83 fields
84}
85
86fn canon_type(t: &TypeRef) -> String {
95 match t {
96 TypeRef::Base(b, _) => b.name().to_string(),
97 TypeRef::Named(id) => id.name.clone(),
98 TypeRef::Result(a, b, _) => format!("Result[{}, {}]", canon_type(a), canon_type(b)),
99 TypeRef::Option(t, _) => format!("Option[{}]", canon_type(t)),
100 TypeRef::Effect(t, _) => format!("Effect[{}]", canon_type(t)),
101 TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", canon_type(t)),
102 TypeRef::QueueResult(_) => "QueueResult".to_string(),
103 TypeRef::Asset(_) => "Asset".to_string(),
104 TypeRef::RequestHeaders(_) => "RequestHeaders".to_string(),
105 TypeRef::TokenClaims(_) => "TokenClaims".to_string(),
106 TypeRef::BoundaryRejection(_) => "BoundaryRejection".to_string(),
107 TypeRef::List(t, _) => format!("List[{}]", canon_type(t)),
108 TypeRef::Map(k, v, _) => format!("Map[{}, {}]", canon_type(k), canon_type(v)),
109 TypeRef::Query(t, _) => format!("Query[{}]", canon_type(t)),
110 TypeRef::Stream(t, _) => format!("Stream[{}]", canon_type(t)),
111 TypeRef::Connection(t, _) => format!("Connection[{}]", canon_type(t)),
112 TypeRef::History(t, _) => format!("History[{}]", canon_type(t)),
113 TypeRef::ValidationError(_) => "ValidationError".to_string(),
114 TypeRef::JsonError(_) => "JsonError".to_string(),
115 TypeRef::Unit(_) => "()".to_string(),
116 TypeRef::Fn(params, ret, _) => format!(
117 "({}) -> {}",
118 params.iter().map(canon_type).collect::<Vec<_>>().join(", "),
119 canon_type(ret)
120 ),
121 TypeRef::App { name, args, .. } => format!(
122 "{}[{}]",
123 name.name,
124 args.iter().map(canon_type).collect::<Vec<_>>().join(", ")
125 ),
126 }
127}
128
129enum Reconciled {
132 Baseline { fields: Vec<FieldShape> },
135 Unchanged {
137 fields: Vec<FieldShape>,
138 stored: i64,
139 },
140 Additive {
142 fields: Vec<FieldShape>,
143 bumped: i64,
144 },
145 NonAdditive {
148 removed: Vec<String>,
149 retyped: Vec<String>,
150 added_without_default: Vec<String>,
151 lost_default: Vec<String>,
152 },
153}
154
155fn reconcile_one(current: &[FieldShape], stored: Option<&EventEntry>) -> Reconciled {
156 let Some(stored) = stored else {
157 return Reconciled::Baseline {
158 fields: current.to_vec(),
159 };
160 };
161 if current == stored.fields.as_slice() {
162 return Reconciled::Unchanged {
163 fields: current.to_vec(),
164 stored: stored.schema,
165 };
166 }
167
168 fn by_name(fields: &[FieldShape]) -> BTreeMap<&str, &FieldShape> {
169 fields.iter().map(|f| (f.name.as_str(), f)).collect()
170 }
171 let old = by_name(&stored.fields);
172 let new = by_name(current);
173
174 let removed: Vec<String> = old
175 .keys()
176 .filter(|n| !new.contains_key(*n))
177 .map(|n| n.to_string())
178 .collect();
179 let retyped: Vec<String> = old
180 .iter()
181 .filter_map(|(n, old_field)| {
182 new.get(n)
183 .filter(|new_field| new_field.ty != old_field.ty)
184 .map(|_| n.to_string())
185 })
186 .collect();
187 let added_without_default: Vec<String> = new
188 .iter()
189 .filter(|(n, f)| !old.contains_key(*n) && !f.default)
190 .map(|(n, _)| n.to_string())
191 .collect();
192 let lost_default: Vec<String> = old
197 .iter()
198 .filter_map(|(n, old_field)| {
199 new.get(n).filter(|new_field| {
200 new_field.ty == old_field.ty && old_field.default && !new_field.default
201 })
202 })
203 .map(|f| f.name.clone())
204 .collect();
205
206 if removed.is_empty()
207 && retyped.is_empty()
208 && added_without_default.is_empty()
209 && lost_default.is_empty()
210 {
211 Reconciled::Additive {
212 fields: current.to_vec(),
213 bumped: stored.schema + 1,
214 }
215 } else {
216 Reconciled::NonAdditive {
217 removed,
218 retyped,
219 lost_default,
220 added_without_default,
221 }
222 }
223}
224
225pub fn reconcile(
231 existing: &SchemaRegistry,
232 unit_tables: &std::collections::HashMap<String, UnitTable>,
233 errors: &mut Vec<CompileError>,
234) -> (SchemaRegistry, std::collections::HashMap<String, i64>) {
235 let mut updated = SchemaRegistry::new();
236 let mut effective = std::collections::HashMap::new();
237
238 let mut units: Vec<_> = unit_tables.iter().collect();
239 units.sort_by_key(|(name, _)| *name);
240
241 for (unit_name, table) in units {
242 let mut events: Vec<_> = table.events.iter().collect();
243 events.sort_by_key(|(name, _)| *name);
244 for (event_name, event) in events {
245 let key = format!("{unit_name}.{event_name}");
246 let fields = snapshot(event);
247 let declared = event.schema_version();
248 let annotation_span = event
249 .annotations
250 .iter()
251 .find(|a| a.name.name == "schema")
252 .map(|a| a.span);
253 let has_annotation = annotation_span.is_some();
254
255 let (effective_version, entry) = match reconcile_one(&fields, existing.get(&key)) {
256 Reconciled::Baseline { fields } => (
257 declared,
258 EventEntry {
259 schema: declared,
260 fields,
261 },
262 ),
263 Reconciled::Unchanged { fields, stored } => {
264 if has_annotation && declared != stored {
265 errors.push(mismatch_error(
266 event_name,
267 annotation_span.unwrap(),
268 declared,
269 stored,
270 ));
271 }
272 (
273 stored,
274 EventEntry {
275 schema: stored,
276 fields,
277 },
278 )
279 }
280 Reconciled::Additive { fields, bumped } => {
281 if has_annotation && declared != bumped {
282 errors.push(mismatch_error(
283 event_name,
284 annotation_span.unwrap(),
285 declared,
286 bumped,
287 ));
288 }
289 (
290 bumped,
291 EventEntry {
292 schema: bumped,
293 fields,
294 },
295 )
296 }
297 Reconciled::NonAdditive {
298 removed,
299 retyped,
300 added_without_default,
301 lost_default,
302 } => {
303 errors.push(non_additive_error(
304 event,
305 event_name,
306 &removed,
307 &retyped,
308 &added_without_default,
309 &lost_default,
310 ));
311 let old = existing
316 .get(&key)
317 .expect("a NonAdditive verdict only fires against a stored entry")
318 .clone();
319 (old.schema, old)
320 }
321 };
322
323 effective.insert(key.clone(), effective_version);
324 updated.insert(key, entry);
325 }
326 }
327
328 (updated, effective)
329}
330
331fn mismatch_error(
332 event_name: &str,
333 span: bynk_syntax::span::Span,
334 declared: i64,
335 computed: i64,
336) -> CompileError {
337 CompileError::new(
338 "bynk.event.schema_version_mismatch",
339 span,
340 format!(
341 "`{event_name}`'s `@schema({declared})` disagrees with the schema \
342 registry, which computes version {computed} from the event's \
343 build history"
344 ),
345 )
346 .with_note(format!(
347 "update the annotation to `@schema({computed})`, or remove it to let \
348 the compiler track the version automatically"
349 ))
350}
351
352fn non_additive_error(
353 event: &EventDecl,
354 event_name: &str,
355 removed: &[String],
356 retyped: &[String],
357 added_without_default: &[String],
358 lost_default: &[String],
359) -> CompileError {
360 let mut parts = Vec::new();
361 if !removed.is_empty() {
362 parts.push(format!("field(s) removed: {}", removed.join(", ")));
363 }
364 if !retyped.is_empty() {
365 parts.push(format!("field(s) retyped: {}", retyped.join(", ")));
366 }
367 if !added_without_default.is_empty() {
368 parts.push(format!(
369 "field(s) added without a default: {}",
370 added_without_default.join(", ")
371 ));
372 }
373 if !lost_default.is_empty() {
374 parts.push(format!(
375 "field(s) lost their default: {}",
376 lost_default.join(", ")
377 ));
378 }
379 CompileError::new(
380 "bynk.event.non_additive_schema_change",
381 event.span,
382 format!(
383 "`{event_name}` changed in a way the schema registry cannot \
384 evolve additively — {}",
385 parts.join("; ")
386 ),
387 )
388 .with_note(
389 "an additive change adds only fields that carry a default; give a \
390 breaking change a new event type name instead",
391 )
392}
393
394fn agent_snapshot(
407 agent: &AgentDecl,
408 types: &std::collections::HashMap<String, std::sync::Arc<bynk_syntax::ast::TypeDecl>>,
409) -> Vec<StoreShape> {
410 let mut fields: Vec<StoreShape> = agent
411 .store_fields
412 .iter()
413 .map(|f| {
414 let (structure, full): (Vec<String>, Vec<String>) = f
415 .kind
416 .args
417 .iter()
418 .map(|a| crate::contract::canon_storage_type(a, types))
419 .unzip();
420 StoreShape {
421 name: f.name.name.clone(),
422 kind: f.kind.head.name.clone(),
423 ty: structure.join(", "),
424 refinement: full.join(", "),
425 }
426 })
427 .collect();
428 fields.sort_by(|a, b| a.name.cmp(&b.name));
429 fields
430}
431
432#[derive(Debug, PartialEq, Eq)]
434enum AgentChange {
435 Unchanged,
436 Additive {
438 refined: Vec<String>,
439 },
440 NonAdditive {
441 removed: Vec<String>,
442 added: Vec<String>,
443 retyped: Vec<String>,
444 rekinded: Vec<String>,
445 },
446}
447
448fn classify_agent_change(current: &[StoreShape], stored: &[StoreShape]) -> AgentChange {
449 if current == stored {
450 return AgentChange::Unchanged;
451 }
452 let old: BTreeMap<&str, &StoreShape> = stored.iter().map(|f| (f.name.as_str(), f)).collect();
453 let new: BTreeMap<&str, &StoreShape> = current.iter().map(|f| (f.name.as_str(), f)).collect();
454 let mut removed = Vec::new();
455 let mut retyped = Vec::new();
456 let mut rekinded = Vec::new();
457 let mut refined = Vec::new();
458 for (name, o) in &old {
459 match new.get(name) {
460 None => removed.push(name.to_string()),
461 Some(n) if n.kind != o.kind => rekinded.push(name.to_string()),
462 Some(n) if n.ty != o.ty => retyped.push(name.to_string()),
463 Some(n) if n.refinement != o.refinement => refined.push(name.to_string()),
464 Some(_) => {}
465 }
466 }
467 let added: Vec<String> = new
468 .keys()
469 .filter(|n| !old.contains_key(*n))
470 .map(|n| n.to_string())
471 .collect();
472 if removed.is_empty() && retyped.is_empty() && rekinded.is_empty() {
473 AgentChange::Additive { refined }
474 } else {
475 AgentChange::NonAdditive {
476 removed,
477 added,
478 retyped,
479 rekinded,
480 }
481 }
482}
483
484fn unambiguous_rename<'a>(
489 removed: &'a [String],
490 added: &'a [String],
491 stored: &[StoreShape],
492 current: &[StoreShape],
493) -> RenameHint<'a> {
494 let shape_of = |fields: &[StoreShape], name: &str| {
495 fields
496 .iter()
497 .find(|f| f.name == name)
498 .map(|f| (f.kind.clone(), f.ty.clone()))
499 };
500 let mut pairs = Vec::new();
501 let mut ambiguous = false;
502 for r in removed {
503 let r_shape = shape_of(stored, r);
504 let candidates: Vec<&String> = added
505 .iter()
506 .filter(|a| shape_of(current, a) == r_shape)
507 .collect();
508 match candidates.as_slice() {
509 [] => {}
510 [a] => {
511 let rivals = removed
512 .iter()
513 .filter(|other| shape_of(stored, other) == r_shape)
514 .count();
515 if rivals == 1 {
516 pairs.push((r.as_str(), a.as_str()));
517 } else {
518 ambiguous = true;
519 }
520 }
521 _ => ambiguous = true,
522 }
523 }
524 match (ambiguous, pairs.as_slice()) {
525 (true, _) => RenameHint::Ambiguous,
526 (false, [one]) => RenameHint::One(one.0, one.1),
527 _ => RenameHint::None,
528 }
529}
530
531#[derive(Debug, PartialEq, Eq)]
532enum RenameHint<'a> {
533 None,
534 One(&'a str, &'a str),
535 Ambiguous,
536}
537
538fn never_deployed_note(key: &str) -> String {
543 format!(
544 "if this class was never deployed, delete its `[agents.\"{key}\"]` entry from \
545 `bynk.schema.lock` to record the new shape as a fresh baseline; if it was deployed, \
546 widen rather than narrow, or stage the change through a new field (ADR 0124 D3), or \
547 declare a `migrate from N` step that carries the old data forward"
548 )
549}
550
551#[derive(Debug, Clone, PartialEq, Eq)]
555pub struct AgentVersions {
556 pub version: i64,
557 pub oldest_readable: i64,
558 pub steps: Vec<MigrationStep>,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct MigrationStep {
566 pub lo: i64,
569 pub from: i64,
570 pub carried: Vec<String>,
573 pub known: Vec<String>,
576 pub store_maps: Vec<String>,
579}
580
581fn kind_has_no_value(kind: &str) -> bool {
584 matches!(kind, "Set" | "Log" | "Cache")
585}
586
587fn step_reads(step: &MigrateDecl) -> std::collections::BTreeSet<String> {
589 fn walk(e: &Expr, old: &str, out: &mut std::collections::BTreeSet<String>) {
590 if let ExprKind::FieldAccess { receiver, field } = &e.kind
591 && matches!(&receiver.kind, ExprKind::Ident(id) if id.name == old)
592 {
593 out.insert(field.name.clone());
594 }
595 for c in bynk_syntax::ast::expr_children(e) {
596 walk(c, old, out);
597 }
598 }
599 let mut out = std::collections::BTreeSet::new();
600 if let Some(old) = &step.old {
601 for item in &step.items {
602 match item {
603 MigrateItem::Let(l) => walk(&l.value, &old.name.name, &mut out),
604 MigrateItem::Assign(a) => walk(&a.value, &old.name.name, &mut out),
605 MigrateItem::Drop(_) => {}
606 }
607 }
608 }
609 out
610}
611
612fn check_step(
619 agent: &AgentDecl,
620 step: &MigrateDecl,
621 entry: &AgentEntry,
622 current: &[StoreShape],
623 types: &std::collections::HashMap<String, std::sync::Arc<bynk_syntax::ast::TypeDecl>>,
624 errors: &mut Vec<CompileError>,
625) -> Option<StepPlanParts> {
626 let n = step.from;
627 if n >= 1 && n < entry.floor {
632 errors.push(
633 CompileError::new(
634 "bynk.agent.migrate_below_floor",
635 step.from_span,
636 format!(
637 "`migrate from {n}`: agent `{}`'s floor is {}, so no stored instance can \
638 hold version {n}; this step can be deleted",
639 agent.name.name, entry.floor
640 ),
641 )
642 .with_note("`bynk migrate sweep` raised the floor; the step emits nothing"),
643 );
644 return None;
645 }
646 let Some(snapshot) = entry
647 .shape(n)
648 .filter(|_| n >= entry.floor && n < entry.version)
649 else {
650 errors.push(
651 CompileError::new(
652 "bynk.agent.migrate_unknown_version",
653 step.from_span,
654 format!(
655 "`migrate from {n}`: the lock records agent `{}` at versions {}..={}, so a \
656 step can read a version from {} to {}",
657 agent.name.name,
658 entry.floor,
659 entry.version,
660 entry.floor,
661 entry.version - 1
662 ),
663 )
664 .with_note("a step reads an older recorded version; the current version needs no step"),
665 );
666 return None;
667 };
668 let stored: BTreeMap<&str, &StoreShape> = snapshot
669 .fields
670 .iter()
671 .map(|f| (f.name.as_str(), f))
672 .collect();
673 let now: BTreeMap<&str, &StoreShape> = current.iter().map(|f| (f.name.as_str(), f)).collect();
674 let mut ok = true;
675 let mut store_maps: Vec<String> = Vec::new();
678
679 if let Some(old) = &step.old
681 && let TypeRef::Named(id) = &old.type_ref
682 && let Some(decl) = types.get(&id.name)
683 && let TypeBody::Record(r) = &decl.body
684 {
685 for f in &r.fields {
686 let recorded_kind = stored.get(f.name.name.as_str()).map(|s| s.kind.as_str());
690 let (kind, ty, full) = match &f.type_ref {
691 TypeRef::Map(k, v, _) if recorded_kind != Some("Cell") => {
692 let (ks, kf) = crate::contract::canon_storage_type(k, types);
693 let (vs, vf) = crate::contract::canon_storage_type(v, types);
694 ("Map", format!("{ks}, {vs}"), format!("{kf}, {vf}"))
695 }
696 other => {
697 let (s, full) = crate::contract::canon_storage_type(other, types);
698 ("Cell", s, full)
699 }
700 };
701 if kind == "Map" {
702 store_maps.push(f.name.name.clone());
703 }
704 match stored.get(f.name.name.as_str()) {
705 Some(s) if s.kind == kind && s.ty == ty => {
706 if s.refinement != full {
707 errors.push(
708 CompileError::new(
709 "bynk.agent.refinement_changed",
710 f.span,
711 format!(
712 "`{}.{}` differs from version {n}'s recorded field only in \
713 its refinement",
714 id.name, f.name.name
715 ),
716 )
717 .with_note(
718 "the step still reads the stored value; its output is validated \
719 against the current type on load",
720 ),
721 );
722 }
723 }
724 Some(s) => {
725 ok = false;
726 errors.push(
727 CompileError::new(
728 "bynk.agent.migrate_shape_mismatch",
729 f.span,
730 format!(
731 "`{}.{}` is declared as `{kind}` of `{ty}`, but version {n} \
732 recorded it as `{}` of `{}`",
733 id.name, f.name.name, s.kind, s.ty
734 ),
735 )
736 .with_note(
737 "declare the old field exactly as version N stored it, \
738 declaring a copy of any nested type that has changed since",
739 ),
740 );
741 }
742 None => {
743 ok = false;
744 errors.push(CompileError::new(
745 "bynk.agent.migrate_shape_mismatch",
746 f.span,
747 format!(
748 "`{}.{}`: agent `{}` had no field `{}` at version {n}",
749 id.name, f.name.name, agent.name.name, f.name.name
750 ),
751 ));
752 }
753 }
754 }
755 }
756
757 let mut dropped = std::collections::BTreeSet::new();
758 let mut assigned = std::collections::BTreeSet::new();
759 for item in &step.items {
760 match item {
761 MigrateItem::Drop(d) => {
762 let name = d.field.name.as_str();
763 if !stored.contains_key(name) && !now.contains_key(name) {
764 ok = false;
765 errors.push(CompileError::new(
766 "bynk.agent.migrate_shape_mismatch",
767 d.field.span,
768 format!(
769 "`drop {name}`: agent `{}` has no field `{name}` at version {n} or now",
770 agent.name.name
771 ),
772 ));
773 }
774 dropped.insert(name.to_string());
775 }
776 MigrateItem::Assign(a) => {
777 assigned.insert(a.target.name.clone());
778 }
779 MigrateItem::Let(_) => {}
780 }
781 }
782 let reads = step_reads(step);
783
784 let mut unread: Vec<String> = Vec::new();
786 let mut unassigned: Vec<String> = Vec::new();
787 for (name, s) in &stored {
788 match now.get(name) {
789 None => {
790 if !reads.contains(*name) && !dropped.contains(*name) {
791 unread.push(name.to_string());
792 }
793 }
794 Some(c) if c.kind != s.kind || c.ty != s.ty => {
795 let no_value = kind_has_no_value(&c.kind) || kind_has_no_value(&s.kind);
796 let covered = assigned.contains(*name) || (no_value && dropped.contains(*name));
797 if !covered {
798 unassigned.push(name.to_string());
799 }
800 }
801 Some(_) => {}
802 }
803 }
804 if !unread.is_empty() || !unassigned.is_empty() {
805 ok = false;
806 let mut parts = Vec::new();
807 if !unread.is_empty() {
808 parts.push(format!(
809 "removed field(s) neither read (`old.f`) nor dropped: {}",
810 unread.join(", ")
811 ));
812 }
813 if !unassigned.is_empty() {
814 parts.push(format!(
815 "changed field(s) not assigned: {}",
816 unassigned.join(", ")
817 ));
818 }
819 errors.push(
820 CompileError::new(
821 "bynk.agent.migrate_incomplete",
822 step.keyword.merge(step.from_span),
823 format!(
824 "`migrate from {n}` does not account for every change from version {n} to \
825 the current shape — {}",
826 parts.join("; ")
827 ),
828 )
829 .with_note(
830 "read a removed field through `old` or `drop` it; assign a changed field (a \
831 `Set`, `Log` or `Cache` field can only be dropped, which starts it afresh)",
832 ),
833 );
834 }
835 if !ok {
836 return None;
837 }
838 let carried = stored
839 .iter()
840 .filter(|(name, s)| {
841 now.get(*name)
842 .is_some_and(|c| c.kind == s.kind && c.ty == s.ty)
843 })
844 .map(|(name, _)| name.to_string())
845 .collect();
846 let known = stored.keys().map(|n| n.to_string()).collect();
847 Some(StepPlanParts {
848 carried,
849 known,
850 store_maps,
851 })
852}
853
854#[derive(Debug, Clone, Default)]
856struct StepPlanParts {
857 carried: Vec<String>,
858 known: Vec<String>,
859 store_maps: Vec<String>,
860}
861
862pub fn reconcile_agents(
871 existing: &SchemaRegistry,
872 updated: &mut SchemaRegistry,
873 unit_tables: &std::collections::HashMap<String, UnitTable>,
874 unit_uses: &std::collections::HashMap<String, Vec<String>>,
875 errors: &mut Vec<CompileError>,
876) -> std::collections::HashMap<String, AgentVersions> {
877 let mut versions = std::collections::HashMap::new();
878 let mut live = std::collections::BTreeSet::new();
879 let mut accounted = std::collections::BTreeSet::new();
883 let mut units: Vec<_> = unit_tables.iter().collect();
884 units.sort_by_key(|(name, _)| *name);
885 for (unit_name, table) in units {
886 let types = crate::symbols::combined_types_for(unit_name, unit_tables, unit_uses);
887 for r in &table.removed_agents {
893 let key = format!("{unit_name}.{}", r.name.name);
894 if existing.get_agent(&key).is_some() {
895 accounted.insert(key);
896 }
897 }
898 let mut agents: Vec<_> = table.agents.iter().collect();
899 agents.sort_by_key(|(name, _)| *name);
900 for (agent_name, agent) in agents {
901 let key = format!("{unit_name}.{agent_name}");
902 live.insert(key.clone());
903 let current = agent_snapshot(agent, &types);
904 let mut transition_step: Option<(i64, Option<StepPlanParts>)> = None;
907 let mut history = existing.get_agent(&key);
910 let mut renamed_now: Option<String> = None;
911 if let Some(old) = &agent.replaces
912 && !table.agents.contains_key(&old.name)
913 {
914 let old_key = format!("{unit_name}.{}", old.name);
915 match (existing.get_agent(&old_key), history) {
916 (None, Some(h)) if h.renamed_from.contains(&old.name) => {}
921 (None, _) => errors.push(
922 CompileError::new(
923 "bynk.agent.replaces_unknown",
924 old.span,
925 format!(
926 "agent `{agent_name}` replaces `{}`, but `bynk.schema.lock` \
927 records no class `{old_key}`",
928 old.name
929 ),
930 )
931 .with_note(
932 "a rename moves a recorded class's data and history; check the old \
933 name, and that it was built with this context",
934 ),
935 ),
936 (Some(_), Some(_)) => errors.push(
937 CompileError::new(
938 "bynk.agent.class_conflict",
939 agent.name.span,
940 format!(
941 "agent `{agent_name}` replaces `{}`, but `{key}` already has its \
942 own recorded history",
943 old.name
944 ),
945 )
946 .with_note(
947 "renaming onto a recorded class would merge two histories (and \
948 Cloudflare refuses a rename onto an existing namespace)",
949 ),
950 ),
951 (Some(old_entry), None) => {
952 accounted.insert(old_key);
953 history = Some(old_entry);
954 renamed_now = Some(old.name.clone());
955 }
956 }
957 }
958 let entry = match history {
959 None => AgentEntry {
960 version: 1,
961 floor: 1,
962 shapes: vec![AgentShape {
963 version: 1,
964 additive: false,
965 fields: current.clone(),
966 }],
967 renamed_from: Vec::new(),
968 },
969 Some(stored_entry) => {
970 let stored = stored_entry
974 .current()
975 .map(|s| s.fields.as_slice())
976 .expect("a parsed agent entry has a snapshot at its version");
977 match classify_agent_change(¤t, stored) {
978 AgentChange::Unchanged => stored_entry.clone(),
979 AgentChange::Additive { refined } => {
980 for name in &refined {
981 errors.push(refinement_changed_warning(agent, name));
982 }
983 let mut next = stored_entry.clone();
984 next.version += 1;
985 next.shapes.push(AgentShape {
986 version: next.version,
987 additive: true,
988 fields: current.clone(),
989 });
990 next
991 }
992 AgentChange::NonAdditive {
993 removed,
994 added,
995 retyped,
996 rekinded,
997 } => {
998 let from = stored_entry.version;
1002 match agent.migrations.iter().find(|m| m.from == from) {
1003 Some(step) => {
1004 let mut probe = stored_entry.clone();
1005 probe.version += 1;
1006 let plan =
1007 check_step(agent, step, &probe, ¤t, &types, errors);
1008 let accepted = plan.is_some();
1009 transition_step = Some((from, plan));
1010 if accepted {
1011 probe.shapes.push(AgentShape {
1012 version: probe.version,
1013 additive: false,
1014 fields: current.clone(),
1015 });
1016 probe
1017 } else {
1018 stored_entry.clone()
1019 }
1020 }
1021 None => {
1022 let hint =
1023 unambiguous_rename(&removed, &added, stored, ¤t);
1024 errors.push(non_additive_state_error(
1025 agent, &key, from, stored, &removed, &retyped, &rekinded,
1026 &hint,
1027 ));
1028 stored_entry.clone()
1029 }
1030 }
1031 }
1032 }
1033 }
1034 };
1035 let mut entry = entry;
1037 if let Some(old) = renamed_now
1038 && !entry.renamed_from.contains(&old)
1039 {
1040 entry.renamed_from.push(old);
1041 }
1042 let mut steps = Vec::new();
1045 for step in &agent.migrations {
1046 let plan = match &transition_step {
1047 Some((from, parts)) if *from == step.from => {
1048 parts.clone().filter(|_| entry.version > step.from)
1049 }
1050 _ => check_step(agent, step, &entry, ¤t, &types, errors),
1051 };
1052 if let Some(parts) = plan {
1053 steps.push(MigrationStep {
1054 lo: entry.oldest_readable_to(step.from),
1055 from: step.from,
1056 carried: parts.carried,
1057 known: parts.known,
1058 store_maps: parts.store_maps,
1059 });
1060 }
1061 }
1062 steps.sort_by_key(|s| s.from);
1063 let oldest_readable = entry.oldest_readable();
1064 for v in entry.floor..entry.version {
1065 let covered =
1066 v >= oldest_readable || steps.iter().any(|s| s.lo <= v && v <= s.from);
1067 if !covered {
1068 errors.push(
1069 CompileError::new(
1070 "bynk.agent.version_without_path",
1071 agent.name.span,
1072 format!(
1073 "agent `{}`'s stored version {v} has no path to version {}: no \
1074 `migrate` step covers it, and its changes since were not all \
1075 additive",
1076 agent.name.name, entry.version
1077 ),
1078 )
1079 .with_note(format!(
1080 "a stored record at version {v} faults on load (`NoMigrationPath`); \
1081 add a `migrate from {v}` step, or ignore this if no instance can \
1082 still hold it"
1083 )),
1084 );
1085 }
1086 }
1087 versions.insert(
1088 key.clone(),
1089 AgentVersions {
1090 version: entry.version,
1091 oldest_readable,
1092 steps,
1093 },
1094 );
1095 updated.insert_agent(key, entry);
1096 }
1097 }
1098
1099 for (key, entry) in existing.agents() {
1100 if live.contains(key) || accounted.contains(key) {
1101 continue;
1102 }
1103 errors.push(
1104 CompileError::new(
1105 "bynk.agent.class_removed",
1106 Span::default(),
1107 format!(
1108 "agent class `{key}` is recorded in `bynk.schema.lock` but no longer \
1109 declared — a deployment's stored instances of it would be orphaned"
1110 ),
1111 )
1112 .with_note(format!(
1113 "declare what became of it: `agent New replaces {class}` renames it (its data \
1114 moves), `removed agent {class}` deletes its data; if `{key}` was never \
1115 deployed, you can instead delete its `[agents.\"{key}\"]` entry from \
1116 `bynk.schema.lock`",
1117 class = key.rsplit('.').next().unwrap_or(key)
1118 )),
1119 );
1120 updated.insert_agent(key.clone(), entry.clone());
1121 }
1122 versions
1123}
1124
1125fn refinement_changed_warning(agent: &AgentDecl, field: &str) -> CompileError {
1126 let span = agent
1127 .store_fields
1128 .iter()
1129 .find(|f| f.name.name == field)
1130 .map_or(agent.name.span, |f| f.span);
1131 CompileError::new(
1132 "bynk.agent.refinement_changed",
1133 span,
1134 format!(
1135 "the refinement on `{}.{field}`'s stored type changed; a stored value that no \
1136 longer satisfies it faults on load (`RehydrationViolation`)",
1137 agent.name.name
1138 ),
1139 )
1140 .with_note(
1141 "widening a refinement is safe; if this narrows it, migrate the stored data before \
1142 deploying",
1143 )
1144}
1145
1146#[allow(clippy::too_many_arguments)]
1147fn non_additive_state_error(
1148 agent: &AgentDecl,
1149 key: &str,
1150 stored_version: i64,
1151 stored_fields: &[StoreShape],
1152 removed: &[String],
1153 retyped: &[String],
1154 rekinded: &[String],
1155 hint: &RenameHint<'_>,
1156) -> CompileError {
1157 let mut parts = Vec::new();
1158 if !removed.is_empty() {
1159 parts.push(format!("field(s) removed: {}", removed.join(", ")));
1160 }
1161 if !retyped.is_empty() {
1162 parts.push(format!("field(s) retyped: {}", retyped.join(", ")));
1163 }
1164 if !rekinded.is_empty() {
1165 parts.push(format!(
1166 "field(s) changed storage kind: {}",
1167 rekinded.join(", ")
1168 ));
1169 }
1170 let mut err = CompileError::new(
1171 "bynk.agent.non_additive_state_change",
1172 agent.name.span,
1173 format!(
1174 "agent `{}`'s stored state changed in a way deployed data cannot be read \
1175 through — {}",
1176 agent.name.name,
1177 parts.join("; ")
1178 ),
1179 );
1180 match hint {
1181 RenameHint::One(from, to) => {
1182 err = err.with_note(format!(
1183 "`{from}` was removed and `{to}` added with the same stored shape: if this \
1184 is a rename, deployed `{from}` values are not carried over to `{to}` — they \
1185 would read as `{to}`'s zero"
1186 ));
1187 let old_ty = format!("{}V{stored_version}", agent.name.name);
1191 let shape = stored_fields.iter().find(|f| f.name == *from);
1192 err = err.with_note(match shape {
1193 Some(s) if s.kind == "Cell" && s.ty.chars().all(|c| c.is_alphanumeric()) => {
1194 format!(
1195 "to carry it, declare `type {old_ty} = {{ {from}: {} }}` and add \
1196 `migrate from {stored_version} (old: {old_ty}) {{ {to} := old.{from} }}` to \
1197 the agent",
1198 s.ty
1199 )
1200 }
1201 _ => format!(
1202 "to carry it, declare a record type `{old_ty}` with `{from}` as version \
1203 {stored_version} stored it, and add `migrate from {stored_version} (old: \
1204 {old_ty}) {{ {to} := old.{from} }}` to the agent"
1205 ),
1206 });
1207 }
1208 RenameHint::Ambiguous => {
1209 err = err.with_note(
1210 "several removed and added fields share a stored shape, so which became \
1211 which cannot be inferred; it will have to be declared",
1212 );
1213 }
1214 RenameHint::None => {}
1215 }
1216 err.with_note(never_deployed_note(key))
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222 use bynk_syntax::ast::{
1223 Annotation, AnnotationArg, BaseType, Expr, ExprId, ExprKind, Ident, RecordBody, Trivia,
1224 };
1225 use bynk_syntax::span::Span;
1226 use std::collections::HashMap as StdHashMap;
1227
1228 fn ident(name: &str) -> Ident {
1229 Ident {
1230 name: name.to_string(),
1231 span: Span::default(),
1232 }
1233 }
1234
1235 fn int_lit(value: i64) -> Expr {
1236 Expr {
1237 id: ExprId::SYNTHETIC,
1238 kind: ExprKind::IntLit {
1239 value,
1240 lexeme: value.to_string(),
1241 },
1242 span: Span::default(),
1243 }
1244 }
1245
1246 fn field(name: &str, ty: TypeRef, has_default: bool) -> bynk_syntax::ast::RecordField {
1247 bynk_syntax::ast::RecordField {
1248 documentation: None,
1249 trivia: Default::default(),
1250 name: ident(name),
1251 type_ref: ty,
1252 refinement: None,
1253 init: has_default.then(|| int_lit(0)),
1254 span: Span::default(),
1255 }
1256 }
1257
1258 fn schema_annotation(n: i64) -> Annotation {
1259 Annotation {
1260 name: ident("schema"),
1261 args: vec![AnnotationArg {
1262 label: None,
1263 value: int_lit(n),
1264 span: Span::default(),
1265 }],
1266 span: Span::default(),
1267 }
1268 }
1269
1270 fn event(
1271 name: &str,
1272 annotations: Vec<Annotation>,
1273 fields: Vec<bynk_syntax::ast::RecordField>,
1274 ) -> EventDecl {
1275 EventDecl {
1276 name: ident(name),
1277 annotations,
1278 body: RecordBody {
1279 trailing_comments: Default::default(),
1280 fields,
1281 span: Span::default(),
1282 },
1283 documentation: None,
1284 span: Span::default(),
1285 trivia: Trivia::default(),
1286 }
1287 }
1288
1289 fn int_ty() -> TypeRef {
1290 TypeRef::Base(BaseType::Int, Span::default())
1291 }
1292 fn string_ty() -> TypeRef {
1293 TypeRef::Base(BaseType::String, Span::default())
1294 }
1295
1296 fn shape(name: &str, ty: &str, default: bool) -> FieldShape {
1297 FieldShape {
1298 name: name.to_string(),
1299 ty: ty.to_string(),
1300 default,
1301 }
1302 }
1303
1304 #[test]
1307 fn canon_type_renders_base_and_generic_shapes() {
1308 assert_eq!(canon_type(&int_ty()), "Int");
1309 assert_eq!(
1310 canon_type(&TypeRef::Option(Box::new(string_ty()), Span::default())),
1311 "Option[String]"
1312 );
1313 assert_eq!(
1314 canon_type(&TypeRef::List(Box::new(int_ty()), Span::default())),
1315 "List[Int]"
1316 );
1317 }
1318
1319 #[test]
1322 fn no_entry_baselines_silently() {
1323 let current = vec![shape("orderId", "String", false)];
1324 match reconcile_one(¤t, None) {
1325 Reconciled::Baseline { fields } => assert_eq!(fields, current),
1326 _ => panic!("expected Baseline"),
1327 }
1328 }
1329
1330 #[test]
1331 fn unchanged_shape_keeps_stored_version() {
1332 let current = vec![shape("orderId", "String", false)];
1333 let stored = EventEntry {
1334 schema: 2,
1335 fields: current.clone(),
1336 };
1337 match reconcile_one(¤t, Some(&stored)) {
1338 Reconciled::Unchanged { stored: v, .. } => assert_eq!(v, 2),
1339 _ => panic!("expected Unchanged"),
1340 }
1341 }
1342
1343 #[test]
1344 fn additive_field_with_default_bumps_version() {
1345 let old = vec![shape("orderId", "String", false)];
1346 let new = vec![
1347 shape("orderId", "String", false),
1348 shape("region", "Region", true),
1349 ];
1350 let stored = EventEntry {
1351 schema: 1,
1352 fields: old,
1353 };
1354 match reconcile_one(&new, Some(&stored)) {
1355 Reconciled::Additive { bumped, .. } => assert_eq!(bumped, 2),
1356 _ => panic!("expected Additive"),
1357 }
1358 }
1359
1360 #[test]
1361 fn field_removed_is_non_additive() {
1362 let old = vec![
1363 shape("orderId", "String", false),
1364 shape("region", "Region", false),
1365 ];
1366 let new = vec![shape("orderId", "String", false)];
1367 let stored = EventEntry {
1368 schema: 1,
1369 fields: old,
1370 };
1371 match reconcile_one(&new, Some(&stored)) {
1372 Reconciled::NonAdditive { removed, .. } => {
1373 assert_eq!(removed, vec!["region".to_string()])
1374 }
1375 _ => panic!("expected NonAdditive"),
1376 }
1377 }
1378
1379 #[test]
1380 fn field_retyped_is_non_additive() {
1381 let old = vec![shape("orderId", "String", false)];
1382 let new = vec![shape("orderId", "Int", false)];
1383 let stored = EventEntry {
1384 schema: 1,
1385 fields: old,
1386 };
1387 match reconcile_one(&new, Some(&stored)) {
1388 Reconciled::NonAdditive { retyped, .. } => {
1389 assert_eq!(retyped, vec!["orderId".to_string()])
1390 }
1391 _ => panic!("expected NonAdditive"),
1392 }
1393 }
1394
1395 #[test]
1396 fn field_added_without_default_is_non_additive() {
1397 let old = vec![shape("orderId", "String", false)];
1398 let new = vec![
1399 shape("orderId", "String", false),
1400 shape("region", "Region", false),
1401 ];
1402 let stored = EventEntry {
1403 schema: 1,
1404 fields: old,
1405 };
1406 match reconcile_one(&new, Some(&stored)) {
1407 Reconciled::NonAdditive {
1408 added_without_default,
1409 ..
1410 } => assert_eq!(added_without_default, vec!["region".to_string()]),
1411 _ => panic!("expected NonAdditive"),
1412 }
1413 }
1414
1415 #[test]
1416 fn a_field_losing_its_default_is_non_additive() {
1417 let old = vec![
1422 shape("orderId", "String", false),
1423 shape("region", "String", true),
1424 ];
1425 let new = vec![
1426 shape("orderId", "String", false),
1427 shape("region", "String", false),
1428 ];
1429 let stored = EventEntry {
1430 schema: 1,
1431 fields: old,
1432 };
1433 match reconcile_one(&new, Some(&stored)) {
1434 Reconciled::NonAdditive { lost_default, .. } => {
1435 assert_eq!(lost_default, vec!["region".to_string()])
1436 }
1437 _ => panic!("expected NonAdditive"),
1438 }
1439 }
1440
1441 fn table_with(events: Vec<(&str, EventDecl)>) -> UnitTable {
1444 UnitTable {
1445 kind: None,
1446 removed_agents: Vec::new(),
1447 types: StdHashMap::new(),
1448 fns: StdHashMap::new(),
1449 methods: StdHashMap::new(),
1450 capabilities: StdHashMap::new(),
1451 providers: StdHashMap::new(),
1452 services: StdHashMap::new(),
1453 agents: StdHashMap::new(),
1454 actors: StdHashMap::new(),
1455 exported_capabilities: Default::default(),
1456 events: events
1457 .into_iter()
1458 .map(|(n, e)| (n.to_string(), e))
1459 .collect(),
1460 flattened_caps: StdHashMap::new(),
1461 assets: StdHashMap::new(),
1462 embeds: StdHashMap::new(),
1463 derived: StdHashMap::new(),
1464 }
1465 }
1466
1467 #[test]
1468 fn reconcile_baselines_a_brand_new_event_at_its_declared_annotation() {
1469 let e = event(
1470 "PaymentConfirmed",
1471 vec![schema_annotation(3)],
1472 vec![field("orderId", string_ty(), false)],
1473 );
1474 let mut units = StdHashMap::new();
1475 units.insert(
1476 "commerce.order".to_string(),
1477 table_with(vec![("PaymentConfirmed", e)]),
1478 );
1479 let existing = SchemaRegistry::new();
1480 let mut errors = Vec::new();
1481 let (updated, effective) = reconcile(&existing, &units, &mut errors);
1482 assert!(errors.is_empty(), "a first-ever compile must not error");
1483 assert_eq!(effective.get("commerce.order.PaymentConfirmed"), Some(&3));
1484 assert_eq!(
1485 updated
1486 .get("commerce.order.PaymentConfirmed")
1487 .unwrap()
1488 .schema,
1489 3
1490 );
1491 }
1492
1493 #[test]
1494 fn reconcile_rejects_a_mismatched_schema_annotation_on_an_unchanged_event() {
1495 let e = event(
1496 "PaymentConfirmed",
1497 vec![schema_annotation(5)],
1498 vec![field("orderId", string_ty(), false)],
1499 );
1500 let mut units = StdHashMap::new();
1501 units.insert(
1502 "commerce.order".to_string(),
1503 table_with(vec![("PaymentConfirmed", e)]),
1504 );
1505 let mut existing = SchemaRegistry::new();
1506 existing.insert(
1507 "commerce.order.PaymentConfirmed".to_string(),
1508 EventEntry {
1509 schema: 2,
1510 fields: vec![shape("orderId", "String", false)],
1511 },
1512 );
1513 let mut errors = Vec::new();
1514 let (_, effective) = reconcile(&existing, &units, &mut errors);
1515 assert_eq!(errors.len(), 1);
1516 assert_eq!(errors[0].category, "bynk.event.schema_version_mismatch");
1517 assert_eq!(effective.get("commerce.order.PaymentConfirmed"), Some(&2));
1520 }
1521
1522 #[test]
1523 fn reconcile_auto_bumps_an_unannotated_additive_change() {
1524 let e = event(
1525 "OrderCancelled",
1526 vec![],
1527 vec![
1528 field("orderId", string_ty(), false),
1529 field("reason", string_ty(), true),
1530 ],
1531 );
1532 let mut units = StdHashMap::new();
1533 units.insert(
1534 "commerce.order".to_string(),
1535 table_with(vec![("OrderCancelled", e)]),
1536 );
1537 let mut existing = SchemaRegistry::new();
1538 existing.insert(
1539 "commerce.order.OrderCancelled".to_string(),
1540 EventEntry {
1541 schema: 1,
1542 fields: vec![shape("orderId", "String", false)],
1543 },
1544 );
1545 let mut errors = Vec::new();
1546 let (updated, effective) = reconcile(&existing, &units, &mut errors);
1547 assert!(errors.is_empty());
1548 assert_eq!(effective.get("commerce.order.OrderCancelled"), Some(&2));
1549 assert_eq!(
1550 updated.get("commerce.order.OrderCancelled").unwrap().schema,
1551 2
1552 );
1553 }
1554
1555 #[test]
1556 fn reconcile_rejects_a_non_additive_change_and_keeps_the_old_entry() {
1557 let e = event(
1558 "OrderCancelled",
1559 vec![],
1560 vec![field("orderId", string_ty(), false)],
1561 );
1562 let mut units = StdHashMap::new();
1563 units.insert(
1564 "commerce.order".to_string(),
1565 table_with(vec![("OrderCancelled", e)]),
1566 );
1567 let mut existing = SchemaRegistry::new();
1568 existing.insert(
1569 "commerce.order.OrderCancelled".to_string(),
1570 EventEntry {
1571 schema: 4,
1572 fields: vec![
1573 shape("orderId", "String", false),
1574 shape("reason", "String", false),
1575 ],
1576 },
1577 );
1578 let mut errors = Vec::new();
1579 let (updated, effective) = reconcile(&existing, &units, &mut errors);
1580 assert_eq!(errors.len(), 1);
1581 assert_eq!(errors[0].category, "bynk.event.non_additive_schema_change");
1582 assert_eq!(effective.get("commerce.order.OrderCancelled"), Some(&4));
1583 assert_eq!(
1584 updated.get("commerce.order.OrderCancelled").unwrap().schema,
1585 4
1586 );
1587 }
1588
1589 #[test]
1590 fn a_stale_key_for_a_renamed_event_is_dropped_silently() {
1591 let e = event(
1596 "PaymentConfirmedV2",
1597 vec![],
1598 vec![field("orderId", string_ty(), false)],
1599 );
1600 let mut units = StdHashMap::new();
1601 units.insert(
1602 "commerce.order".to_string(),
1603 table_with(vec![("PaymentConfirmedV2", e)]),
1604 );
1605 let mut existing = SchemaRegistry::new();
1606 existing.insert(
1607 "commerce.order.PaymentConfirmed".to_string(),
1608 EventEntry {
1609 schema: 3,
1610 fields: vec![shape("orderId", "String", false)],
1611 },
1612 );
1613 let mut errors = Vec::new();
1614 let (updated, _) = reconcile(&existing, &units, &mut errors);
1615 assert!(errors.is_empty());
1616 assert!(updated.get("commerce.order.PaymentConfirmed").is_none());
1617 assert!(updated.get("commerce.order.PaymentConfirmedV2").is_some());
1618 }
1619
1620 #[test]
1627 fn parse_or_diagnose_passes_through_a_valid_registry() {
1628 let mut reg = SchemaRegistry::new();
1629 reg.insert(
1630 "commerce.order.PaymentConfirmed".to_string(),
1631 EventEntry {
1632 schema: 1,
1633 fields: vec![shape("orderId", "String", false)],
1634 },
1635 );
1636 let text = bynk_project::schema_registry::serialize(®);
1637 let parsed = parse_or_diagnose(Some(&text), std::path::Path::new("/tmp"))
1638 .expect("a freshly serialized registry must parse");
1639 assert_eq!(
1640 parsed
1641 .get("commerce.order.PaymentConfirmed")
1642 .map(|e| e.schema),
1643 Some(1)
1644 );
1645 }
1646
1647 #[test]
1648 fn parse_or_diagnose_reports_a_corrupt_registry_under_its_own_code() {
1649 let err = parse_or_diagnose(Some("not valid toml {{{"), std::path::Path::new("/tmp"))
1650 .expect_err("garbage content must not parse");
1651 assert_eq!(err.category, "bynk.project.schema_registry_corrupt");
1652 }
1653
1654 #[test]
1655 fn parse_or_diagnose_with_no_existing_content_baselines_empty() {
1656 let parsed = parse_or_diagnose(None, std::path::Path::new("/tmp"))
1657 .expect("no lock file yet is not corruption");
1658 assert!(parsed.get("anything.at_all").is_none());
1659 }
1660}