Migrate frozen data contracts and authored source to schema 4

This commit is contained in:
2026-09-06 03:39:02 +03:00
parent c5276b1943
commit 1230ab8a3e
21 changed files with 2006 additions and 126 deletions
+33
View File
@@ -0,0 +1,33 @@
# Schema 3 to 4 reader
Schema 3 is a real compatibility input. Its shapes were frozen at `5ce3eef`.
The small DTOs in `mod.rs` own the changed data payloads and old message values;
closed element/relation constructor lists prevent new vocabulary from entering
the old reader. Unchanged ports, diagram placements/routes, artifacts and traces
reuse their typed records. The intermediate JSON bridge rejects duplicate keys.
Nature, bounded types, field defaults/direction/storage, and single supertypes
receive explicit documented defaults. Interface lists become identified ordered
allocations with UUID v5 namespace equal to the project UUID and name
`v2-interface-item:<interface-uuid>:<item-uuid>`. The name remains the same for
V2 and V3 inputs so the migration route cannot change the generated identity.
Existing IDs never move; deterministic collisions fail with a located diagnostic.
Aliases use the normal deterministic collision suffix. Unique legacy message
paths are qualified; ambiguous annotations retain their expression and warning.
V1 first normalizes to the frozen V2 DTO. V2 port/endpoint normalization emits
an explicit schema3 candidate and then runs this reader before current decoding.
Generated object, record and reference budgets cover the combined stages.
`language::resolve_declarations` shares lexical/AST parsing, including nesting,
but chooses frozen source3 payload defaults and rejects new constructors. Both
single-file parsing and `source_project::compile_bundle` take this explicit path.
The bundle compiler first stabilizes omitted IDs, then performs targeted token
edits in `source_project/source_migration.rs`. Original files, includes, lexical
ownership and comments survive. New allocation declarations stay in the source
interface's module. The returned source and index describe schema4 bytes; loading
does not rewrite original disk files. Canonical output and second-save stability
are schema4. No whole-project formatting fallback handles authored source3.
Frozen source/JSON fixtures live under `tests/fixtures/v3-data-contract.*`; the
original V2 golden files remain unchanged.
+630
View File
@@ -0,0 +1,630 @@
//! Explicit schema-3 compatibility. Only changed payloads are frozen here;
//! unchanged typed records are shared after a closed old-constructor check.
use crate::language::ParseLimits;
use crate::persistence::{DecodeResult, FileError, MigrationReport};
use crate::*;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project3 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element3>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation3>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Element3 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub parent: Option<Id>,
#[serde(deserialize_with = "strict_value")]
pub body: Value,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Relation3 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "strict_value")]
pub body: Value,
}
// A Value bridge must retain the strict duplicate-field behavior of typed DTOs.
fn strict_value<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Value, D::Error> {
struct Strict;
impl<'de> serde::de::Visitor<'de> for Strict {
type Value = Value;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a value with unique object fields")
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Value, E> {
Ok(v.into())
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Value, E> {
Ok(v.into())
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Value, E> {
Ok(v.into())
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Value, E> {
serde_json::Number::from_f64(v)
.map(Value::Number)
.ok_or_else(|| E::custom("Non-finite number"))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Value, E> {
Ok(v.into())
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Value, E> {
Ok(v.into())
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
let mut out = Vec::new();
while let Some(v) = a.next_element::<StrictValue>()? {
out.push(v.0);
}
Ok(Value::Array(out))
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
let mut out = serde_json::Map::new();
while let Some((k, v)) = a.next_entry::<String, StrictValue>()? {
if out.insert(k.clone(), v.0).is_some() {
return Err(serde::de::Error::custom(format!("Duplicate field '{k}'")));
}
}
Ok(Value::Object(out))
}
}
d.deserialize_any(Strict)
}
struct StrictValue(Value);
impl<'de> Deserialize<'de> for StrictValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
strict_value(d).map(Self)
}
}
#[derive(Deserialize)]
enum ItemKind3 {
Information,
Material,
Energy,
Event,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
enum Definition3 {
Boolean,
Integer,
Real,
String,
Enumeration { literals: Vec<String> },
Record,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Item3 {
kind: ItemKind3,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Type3 {
definition: Definition3,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Field3 {
type_ref: TypeRef,
minimum: u32,
maximum: Option<u32>,
unit: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Interface3 {
items: Vec<Id>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MessageValue3 {
field: Id,
expression: String,
}
pub(crate) fn relation_allowed(name: &str) -> bool {
matches!(
name,
"Allocation"
| "PortAllocation"
| "Interaction"
| "CapabilityInvolvement"
| "ChainInvolvement"
| "RoleRealization"
| "RoleAssignment"
| "FunctionalExchange"
| "BoundaryExchange"
| "MissionCapability"
| "CapabilityInclude"
| "CapabilityExtend"
| "CapabilityGeneralization"
| "MissionInvolvement"
| "SequenceLink"
| "StateTransition"
| "Availability"
| "ExchangeAllocation"
| "InterfaceProvision"
| "InterfaceRequirement"
| "StateAction"
)
}
pub(crate) fn element_allowed(name: &str) -> bool {
matches!(
name,
"Entity"
| "Actor"
| "Role"
| "Activity"
| "Capability"
| "OperationalMission"
| "Process"
| "System"
| "SystemActor"
| "Function"
| "SystemCapability"
| "Mission"
| "FunctionalChain"
| "ExchangeItem"
| "DataType"
| "DataField"
| "Interface"
| "Port"
| "Scenario"
| "Lifeline"
| "Occurrence"
| "StateMachine"
| "Region"
| "State"
| "Mode"
| "Initial"
| "Final"
| "Choice"
| "Junction"
| "Fork"
| "Join"
| "ShallowHistory"
| "DeepHistory"
| "EntryPoint"
| "ExitPoint"
| "Terminate"
| "Event"
| "Constraint"
)
}
pub(crate) fn legacy_defaults(kind: Kind) -> Option<Value> {
Some(match kind {
Kind::ExchangeItem => json!({"kind":"Information"}),
Kind::DataType => json!({"definition":"String"}),
Kind::DataField => json!({"type_ref":"String","minimum":1,"maximum":1,"unit":""}),
Kind::Interface => json!({"items":[]}),
_ => return None,
})
}
fn at(id: Id, field: &str, code: &str, message: impl Into<String>) -> FileError {
let message = message.into();
FileError::Model {
message: format!("V3 {code} at {id}.{field}: {message}"),
issues: vec![validation::Issue {
severity: validation::Severity::Error,
element: Some(id),
code: code.into(),
field: Some(field.into()),
message,
}],
}
}
fn singleton(value: &Value) -> Option<(&str, &Value)> {
let object = value.as_object()?;
(object.len() == 1)
.then(|| object.iter().next().unwrap())
.map(|(k, v)| (k.as_str(), v))
}
fn old_payload<T: serde::de::DeserializeOwned>(
id: Id,
field: &str,
value: Value,
) -> Result<T, FileError> {
serde_json::from_value(value).map_err(|error| at(id, field, "legacy_shape", error.to_string()))
}
pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult, FileError> {
if old.schema_version != 3 {
return Err(FileError::Invalid("Expected schema_version 3.".into()));
}
let graph = old.elements.len().saturating_add(old.relations.len());
let records = graph
.saturating_add(old.diagrams.len())
.saturating_add(old.diagrams.values().map(|d| d.nodes.len()).sum::<usize>())
.saturating_add(old.traces.len())
.saturating_add(old.artifact_links.len())
.saturating_add(1);
limits
.check_counts(graph, records, 0)
.map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(3);
let mut value = serde_json::to_value(&old)?;
let mut allocations = Vec::new();
for (key, e) in &old.elements {
if *key != e.id {
return Err(at(
e.id,
"id",
"identity",
"Element map key differs from its identity.",
));
}
let mut body = e.body.clone();
let (layer, inner) = singleton(&body)
.filter(|(name, _)| matches!(*name, "Oa" | "Sa"))
.ok_or_else(|| {
at(
e.id,
"body",
"legacy_shape",
"Expected one OA or SA constructor.",
)
})?;
let layer = layer.to_owned();
let outer = inner
.as_str()
.or_else(|| singleton(inner).map(|(name, _)| name));
if !outer.is_some_and(|name| {
if layer == "Oa" {
matches!(
name,
"Entity"
| "Actor"
| "Role"
| "Activity"
| "Capability"
| "Mission"
| "Process"
| "Shared"
)
} else {
matches!(
name,
"System"
| "Actor"
| "Function"
| "Capability"
| "Mission"
| "FunctionalChain"
| "Shared"
)
}
}) {
return Err(at(
e.id,
"body",
"legacy_shape",
"Unknown schema3 layer constructor.",
));
}
if let Some(("Shared", shared)) = singleton(inner) {
let name = shared
.as_str()
.or_else(|| singleton(shared).map(|(name, _)| name))
.ok_or_else(|| {
at(
e.id,
"body",
"legacy_shape",
"Expected one schema3 shared constructor.",
)
})?
.to_owned();
if !matches!(
name.as_str(),
"ExchangeItem"
| "DataType"
| "DataField"
| "Interface"
| "Port"
| "Scenario"
| "Lifeline"
| "Occurrence"
| "StateMachine"
| "Event"
| "Region"
| "State"
| "Constraint"
) {
return Err(at(
e.id,
"body",
"legacy_shape",
format!("Unknown schema3 shared constructor '{name}'."),
));
}
let payload = shared.get(&name).cloned().unwrap_or(Value::Null);
let changed = match name.as_str() {
"ExchangeItem" => {
let old: Item3 = old_payload(e.id, "body.kind", payload)?;
let (kind, mechanism) = match old.kind {
ItemKind3::Information => ("Information", "Unspecified"),
ItemKind3::Material => ("Material", "Unspecified"),
ItemKind3::Energy => ("Energy", "Unspecified"),
ItemKind3::Event => ("Information", "Event"),
};
Some(json!({"kind":kind,"mechanism":mechanism,"supertype":null}))
}
"DataType" => {
let old: Type3 = old_payload(e.id, "body.definition", payload)?;
let definition = match old.definition {
Definition3::Boolean => json!("Boolean"),
Definition3::Integer => json!({"Integer":{"minimum":null,"maximum":null}}),
Definition3::Real => json!({"Real":{"minimum":null,"maximum":null}}),
Definition3::String => {
json!({"String":{"minimum_length":0,"maximum_length":null}})
}
Definition3::Enumeration { literals } => {
json!({"Enumeration":{"literals":literals}})
}
Definition3::Record => json!({"Record":{"supertype":null}}),
};
Some(json!({"definition":definition,"default":null,"unit":""}))
}
"DataField" => {
let old: Field3 = old_payload(e.id, "body", payload)?;
Some(
json!({"type_ref":old.type_ref,"minimum":old.minimum,"maximum":old.maximum,"unit":old.unit,"direction":"Unspecified","storage":"Value","default":null}),
)
}
"Interface" => {
let old: Interface3 = old_payload(e.id, "items", payload)?;
let mut seen = BTreeSet::new();
for (order, item) in old.items.into_iter().enumerate() {
if !seen.insert(item) {
return Err(at(
e.id,
"items",
"duplicate_reference",
"Interface items must be unique.",
));
}
allocations.push((e.id, item, order));
}
Some(json!({"supertype":null}))
}
"Occurrence" => {
let mut payload = payload;
if let Some(values) = payload.pointer_mut("/body/Exchange/values") {
let old: Vec<MessageValue3> =
old_payload(e.id, "body.values", values.clone())?;
*values=Value::Array(old.into_iter().map(|v|json!({"field":v.field,"expression":v.expression,"path":[]})).collect());
}
Some(payload)
}
_ => None,
};
if let Some(changed) = changed {
let description = match name.as_str() {
"ExchangeItem" if changed["mechanism"] == "Event" => {
"Legacy Event nature becomes Information with Event mechanism; supertype is absent."
}
"ExchangeItem" => {
"Nature is retained; mechanism is Unspecified and supertype is absent."
}
"DataType" => {
"Existing type is retained with unbounded numeric/string limits, no scalar default or unit, and no Record supertype."
}
"DataField" => {
"Type, ownership, multiplicity and unit are retained; direction is Unspecified, storage is Value and default is absent."
}
"Interface" => {
"Items become identified ordered allocations with Unspecified protocols; supertype is absent."
}
_ => {
"Value expressions are retained; unique enclosing field paths are resolved and ambiguous legacy paths remain unresolved."
}
};
body[&layer]["Shared"][&name] = changed;
report
.semantic_defaults
.push(crate::persistence::SemanticDefault {
object: e.id,
field: "body".into(),
description: description.into(),
});
}
}
// Current deserialization checks the reused, unchanged outer/typed parts.
let _: ElementBody = serde_json::from_value(body.clone())
.map_err(|error| at(e.id, "body", "legacy_shape", error.to_string()))?;
value["elements"][key.to_string()]["body"] = body;
}
for (key, r) in &old.relations {
if *key != r.id {
return Err(at(
r.id,
"id",
"identity",
"Relation map key differs from its identity.",
));
}
if !singleton(&r.body).is_some_and(|(name, _)| relation_allowed(name)) {
return Err(at(
r.id,
"body",
"legacy_shape",
"Relation constructor was not part of schema3.",
));
}
}
value["schema_version"] = SCHEMA_VERSION.into();
let mut project: Project = serde_json::from_value(value)?;
let mut references = project
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.sum::<usize>()
.saturating_add(allocations.len().saturating_mul(2));
limits
.check_counts(
project
.elements
.len()
.saturating_add(project.relations.len())
.saturating_add(allocations.len()),
project.identities().len().saturating_add(allocations.len()),
references,
)
.map_err(FileError::Limit)?;
let mut ids: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
for (source, target, order) in allocations {
let id = Id::new_v5(
&project.id,
format!("v2-interface-item:{source}:{target}").as_bytes(),
);
if !ids.insert(id) {
return Err(at(
source,
"items",
"identity_collision",
format!(
"Deterministic interface allocation {id} already belongs to another object."
),
));
}
let name = format!("{} · {}", project.named(source), project.named(target));
let alias = project.fresh_alias(
&format!(
"{}_{}_allocation",
project
.elements
.get(&source)
.map_or("interface", |e| e.alias.as_str()),
project
.elements
.get(&target)
.map_or("item", |e| e.alias.as_str())
),
"interface_item_allocation",
id,
);
project.relations.insert(
id,
Relation {
id,
alias: alias.clone(),
name,
description: String::new(),
body: RelationBody::InterfaceItemAllocation {
source,
target,
order: order.try_into().map_err(|_| {
at(source, "items", "model_limit", "Too many interface items.")
})?,
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
},
},
);
report.generated_interface_allocations.push(
crate::persistence::GeneratedInterfaceAllocation {
id,
source,
target,
alias,
order: order as u32,
},
);
}
let annotations: Vec<_> = project
.elements
.values()
.filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
values,
..
},
..
}) => Some((e.id, *exchange, values.clone())),
_ => None,
})
.collect();
for (owner, exchange, values) in annotations {
let Some(relation) = project.relations.get(&exchange) else {
continue;
};
for (index, value) in values.into_iter().enumerate() {
let resolution =
crate::data::resolve_legacy_field_path(&project, relation, value.field)
.map_err(|message| at(owner, "body.values", "model_limit", message))?;
if let crate::data::LegacyFieldPathResolution::Unique(path) = resolution {
if !path.is_empty() {
references = references.saturating_add(path.len());
limits
.check_counts(
project
.elements
.len()
.saturating_add(project.relations.len()),
project.identities().len(),
references,
)
.map_err(FileError::Limit)?;
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project
.elements
.get_mut(&owner)
.and_then(Element::shared_mut)
{
values[index].path = path;
}
}
} else if matches!(
resolution,
crate::data::LegacyFieldPathResolution::Ambiguous
) {
report.unresolved_message_values.push(crate::persistence::UnresolvedMessageValue { occurrence:owner,field:value.field,reason:"Several carried paths reach this legacy field; choose an explicit argument path.".into() });
}
}
}
limits.check_model(&project).map_err(FileError::Limit)?;
super::validate_project(&project)?;
Ok(DecodeResult {
project,
migration: Some(report),
})
}