Skip to main content

bynk_check/
schema_registry.rs

1//! Events track, slice 3c (#980): the cross-build schema registry's
2//! reconciliation logic — verifying a project's live event shapes against a
3//! committed `bynk.schema.lock`, keyed by qualified event name, storing each
4//! event's field shape (name, type, whether the field has a default) and its
5//! current schema version. Every build reconciles the live shape against the
6//! stored one and either leaves the version alone, bumps it (a purely
7//! additive change), or fails the build (a change that isn't safely
8//! additive).
9//!
10//! P4.0 (#1113, [DECISION A]): the document shape itself — `SchemaRegistry`,
11//! `EventEntry`, `FieldShape`, and their `parse`/`serialize` — moved to
12//! `bynk_project::schema_registry`; `bynk-emit/src/project/schema_registry.rs`
13//! re-exports it there, unchanged, for every existing `schema_registry::X`
14//! call site in `project.rs`.
15//!
16//! P5.3 (`design/tracks/semantics-in-the-checker.md` §6): [`reconcile`] and
17//! its helpers moved here from that same `bynk-emit` module — category 1 of
18//! `bynk-check/src/analysis.rs`'s own seven-category residual-gap accounting
19//! ("gap in name only" at the time: `reconcile` only ever ran under
20//! `SchemaLock::On`, and the analyse path always passed `SchemaLock::Off`, so
21//! relocating it changed nothing observable — see that module's doc for why
22//! R3.5 still required the move; #2081 later gave the analyse path the
23//! caller's lock). `bynk-emit`'s `run_checks` is now this function's caller, not its
24//! owner, the same way P4.0/P4.1 turned `project.rs` into a caller of
25//! `bynk-project`/`bynk-check` throughout.
26//!
27//! P5.5 (§6, §3.2's "eighth site"): [`parse_or_diagnose`] moved here too —
28//! `bynk.project.schema_registry_corrupt`, a real `CompileError::new`
29//! construction the seven-category accounting above didn't cover (it isn't a
30//! whole-project *check*, it's the read step immediately before
31//! [`reconcile`]), found and scoped by name in §3.2 rather than left for a
32//! future sweep to rediscover. `bynk_project::schema_registry::parse` itself
33//! — the document-shape parse — stays in `bynk-project` (P4.0); only the
34//! diagnostic construction on its error path moves, the same split
35//! `reconcile`'s own move drew between document shape and checking.
36
37use 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
51/// Parse `bynk.schema.lock`'s content, diagnosing a corrupt file as
52/// `bynk.project.schema_registry_corrupt` rather than handing the caller a
53/// bare `String` to wrap itself. `existing`/`project_root` are exactly
54/// `bynk_project::schema_registry::parse`'s own parameters — `None` (no lock
55/// file yet) always succeeds with a fresh, empty registry.
56pub 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
65/// A shallow, per-field snapshot of an event's current shape — deliberately
66/// **not** `bynk-check/src/contract.rs`'s `canon_named_in`: that renders a
67/// field as `name: type` with no signal for default-presence, so an additive
68/// change (new field, has a default) and a breaking one (new field, no
69/// default) perturb it identically. This snapshot exists to tell those two
70/// apart, one field at a time.
71fn 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
86/// A stable surface-form rendering of a `TypeRef`, for the registry's own
87/// stored shape only. Deliberately **not** `bynk-fmt`'s `type_ref_to_string`
88/// (private, and neither `bynk-emit` nor `bynk-check` depends on `bynk-fmt`)
89/// — and deliberately not required to match either the formatter's or the
90/// checker's own rendering byte-for-byte: this string is compared only
91/// against itself, across builds, never displayed to a user. Exhaustive, no
92/// wildcard arm, so a new `TypeRef` variant is a compile error here rather
93/// than a silent "no signal" gap in the registry's diffing.
94fn 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
129/// The outcome of reconciling one event against its (possibly absent)
130/// registry entry.
131enum Reconciled {
132    /// No prior entry: baseline silently at `declared` (the event's current
133    /// `@schema(N)`, or `1`).
134    Baseline { fields: Vec<FieldShape> },
135    /// Shape unchanged since the stored entry.
136    Unchanged {
137        fields: Vec<FieldShape>,
138        stored: i64,
139    },
140    /// Every added field carries a default; nothing removed or retyped.
141    Additive {
142        fields: Vec<FieldShape>,
143        bumped: i64,
144    },
145    /// A field was removed, retyped, added without a default, or lost a
146    /// default it previously had.
147    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    // A field present in both, same type, that had a default and no longer
193    // does: an older wire event that omitted this key (relying on the
194    // default) can no longer decode — the same forward-compatibility break
195    // as a field added without one, just approached from the other side.
196    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
225/// Reconcile every event across every unit against the given registry.
226/// Returns the updated registry document and the effective schema version
227/// for each event, keyed the same way as the document itself
228/// (`<unit>.<EventName>`). Diagnostics for a mismatched `@schema(N)` or a
229/// non-additive change are pushed onto `errors`.
230pub 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                    // Keep the old entry as-is: a build that fails here
312                    // must not perturb the registry (`compile_project`
313                    // skips the write on any error too — this just keeps
314                    // the in-memory document consistent with that).
315                    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
394// ---------------------------------------------------------------------------
395// State-migrations S0 (#2204; the retired track's summary in
396// `design/archive/retired-tracks.md`, §3.1, §3.4): the agent-state half of the
397// registry. Every agent class's stored shape is
398// recorded per version under `[agents]`, and a change deployed data cannot be
399// read through is refused here, before it can orphan anything.
400// ---------------------------------------------------------------------------
401
402/// The stored shape of one agent class: one [`StoreShape`] per `store` field,
403/// sorted by name (stored state is a JSON object, so order is invisible).
404/// Each type is rendered twice by `contract.rs`'s canonical walk (track
405/// §3.1, #2204 Decision A): structurally, and with refinements.
406fn 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/// How one agent class's live shape relates to its recorded current shape.
433#[derive(Debug, PartialEq, Eq)]
434enum AgentChange {
435    Unchanged,
436    /// Fields added, and/or refinements changed with the structure intact.
437    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
484/// The rename the author most likely made, when it is unambiguous: exactly
485/// one removed field and exactly one added field share a storage kind and a
486/// structure, and neither matches any other candidate. Never applied — only
487/// named in the diagnostic's help (track §3.4: a pairing is never guessed).
488fn 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
538/// The way past a refusal until the migration slices land (#2204 Decision C).
539/// The toolchain cannot know whether a class was ever deployed — the deploy
540/// ledger records no Durable Object classes (ADR 0194 D1, ADR 0443) — so the
541/// note asks the author.
542fn 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/// The two facts the emitter needs to stamp and check an agent's stored
552/// state (state-migrations S1, #2211 Decision B): the class's lock `version`,
553/// and the oldest stored version it reads forward without a migration step.
554#[derive(Debug, Clone, PartialEq, Eq)]
555pub struct AgentVersions {
556    pub version: i64,
557    pub oldest_readable: i64,
558    /// State-migrations S2 (#2224 Decision C): each valid `migrate from N`
559    /// step, sorted by `N`.
560    pub steps: Vec<MigrationStep>,
561}
562
563/// What the emitter needs to run one `migrate from N` step (#2224 Decision D).
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct MigrationStep {
566    /// The stored versions the step covers: `from` and every older version,
567    /// down to `lo`, whose path to `from` is entirely additive (Decision C).
568    pub lo: i64,
569    pub from: i64,
570    /// Version `from`'s fields the current shape keeps with the same
571    /// structure: carried over as they are.
572    pub carried: Vec<String>,
573    /// Every field version `from` recorded: a stored key outside this set
574    /// (and `__v`) is an unknown key, kept rather than dropped.
575    pub known: Vec<String>,
576    /// The old type's `Map[K, V]` fields that read a store `Map` (persisted as
577    /// a `Record`), rather than a `Cell` holding a map value.
578    pub store_maps: Vec<String>,
579}
580
581/// The storage kinds a migration step can neither read nor assign: they have
582/// no value type (#2224 Decision A).
583fn kind_has_no_value(kind: &str) -> bool {
584    matches!(kind, "Set" | "Log" | "Cache")
585}
586
587/// The fields a step reads off its `old` parameter (`old.f` in its body).
588fn 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
612/// Check one `migrate from N` step against the lock (#2224 Decisions A, C): its
613/// version is recorded and below the current one, each field of its old type
614/// matches version N's recorded field on structure (refinement-only
615/// differences warn), each `drop` names a real field, and it accounts for
616/// every change from version N to the current shape. Returns whether it holds;
617/// diagnostics go onto `errors`.
618fn 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    // State-migrations S4 (#2259 Decision G): a complete sweep raised the
628    // floor past N. Versions are numbered contiguously from 1, so N was
629    // recorded once, but no stored instance can hold it now: the step is dead
630    // code, not an error. Its snapshot was dropped with the floor rise.
631    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    // The old type's fields that read a store `Map` (a `Record` on disk), as
676    // opposed to a `Cell` holding a map value (already in its value wire form).
677    let mut store_maps: Vec<String> = Vec::new();
678
679    // The old type's fields against version N, on structure.
680    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            // A `Map[K, V]` field names either a store `Map` of `K, V` or a
687            // `Cell` whose value is a map: version N's recorded kind decides
688            // (#2243 review). Every other type names a `Cell`.
689            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    // Coverage against the current shape (#2224 Decision C).
785    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/// What a passing [`check_step`] hands to [`MigrationStep`].
855#[derive(Debug, Clone, Default)]
856struct StepPlanParts {
857    carried: Vec<String>,
858    known: Vec<String>,
859    store_maps: Vec<String>,
860}
861
862/// Reconcile every agent class across every unit against `existing`, writing
863/// each class's entry into `updated` (the document [`reconcile`] already
864/// rebuilt for events). Diagnostics go onto `errors`; a refused change keeps
865/// the class's stored entry untouched, as a refused event change does.
866///
867/// Returns each live class's [`AgentVersions`], keyed `<unit>.<Agent>`, read
868/// from the *reconciled* entry — so a read-only `check`/`test` reports the
869/// version the next build will record.
870pub 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    // State-migrations S3 (#2248, Decision C): recorded classes a declaration
880    // accounts for — renamed away, or removed — so they are not reported
881    // `class_removed` and are not carried forward under their old key.
882    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        // A `removed agent` accounts for its recorded entry, which is dropped.
888        // A name the lock doesn't record needs nothing: after the build that
889        // drops it the lock can't tell "already removed" from "never
890        // recorded", and Cloudflare treats a `deleted` tombstone for a
891        // namespace that doesn't exist as stale, with no effect.
892        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            // The step that carries the stored version across this build's
905            // non-additive change, when one is declared and checked here.
906            let mut transition_step: Option<(i64, Option<StepPlanParts>)> = None;
907            // State-migrations S3 (#2248, Decisions B, C): a rename takes the
908            // old class's recorded history as this class's own.
909            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                    // Already applied: an earlier build moved the entry here,
917                    // and the declaration stays until Cloudflare reports its
918                    // tombstone stale. `renamed_from` tells that apart from a
919                    // mistyped old name (#2253 review).
920                    (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                    // `parse` refuses an entry whose `version` names no
971                    // snapshot (`bynk.project.schema_registry_corrupt`), so
972                    // there is always one to compare against.
973                    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(&current, 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                            // State-migrations S2 (#2224 Decision C): a
999                            // `migrate from <stored version>` step that checks
1000                            // against the lock carries the change.
1001                            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, &current, &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, &current);
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            // A rename applied by this build is remembered on the entry.
1036            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            // Every other step is checked against the reconciled entry and
1043            // the current shape; each valid one covers a range of versions.
1044            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, &current, &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            // State-migrations S2 (#2224): name the step that carries it. A
1188            // `Cell` whose stored type is a plain name spells the old type
1189            // exactly; anything structural is described instead.
1190            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    // -- canon_type -----------------------------------------------------
1305
1306    #[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    // -- reconcile_one ----------------------------------------------------
1320
1321    #[test]
1322    fn no_entry_baselines_silently() {
1323        let current = vec![shape("orderId", "String", false)];
1324        match reconcile_one(&current, 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(&current, 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        // An older wire event that omitted `region` (relying on the default)
1418        // could no longer decode once the default is gone — the same
1419        // forward-compatibility break as adding a field without one, from
1420        // the other direction.
1421        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    // -- reconcile (full pipeline, with real EventDecls) -------------------
1442
1443    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        // The registry's computed version wins as the effective one, not the
1518        // author's stale annotation.
1519        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        // The old table had `PaymentConfirmed`; the new one only has
1592        // `PaymentConfirmedV2` (the track's prescribed rename-for-breaking-
1593        // change path). No diagnostic, and the old key does not survive into
1594        // the updated document.
1595        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    // `serialize`, and `parse`'s own parsing rules, are
1621    // `bynk_project::schema_registry`'s (P4.0, #1113) — covered by that
1622    // crate's own tests; nothing left to duplicate here.
1623
1624    // -- parse_or_diagnose (P5.5) ------------------------------------------
1625
1626    #[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(&reg);
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}