Migrate schema 2 ports through frozen readers and expose schema 3 source API
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1 migration.
|
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
|
||||||
|
#[path = "migration_v2/source.rs"]
|
||||||
|
mod v2_source;
|
||||||
use crate::*;
|
use crate::*;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::{collections::BTreeMap, fmt};
|
use std::{collections::BTreeMap, fmt};
|
||||||
@@ -317,6 +319,8 @@ fn reference_field(key: &str) -> bool {
|
|||||||
| "branches"
|
| "branches"
|
||||||
| "Named"
|
| "Named"
|
||||||
| "items"
|
| "items"
|
||||||
|
| "incoming_items"
|
||||||
|
| "outgoing_items"
|
||||||
| "triggers"
|
| "triggers"
|
||||||
| "effects"
|
| "effects"
|
||||||
| "interface"
|
| "interface"
|
||||||
@@ -351,6 +355,8 @@ fn enum_field(key: &str) -> bool {
|
|||||||
key,
|
key,
|
||||||
"kind"
|
"kind"
|
||||||
| "direction"
|
| "direction"
|
||||||
|
| "orientation"
|
||||||
|
| "contract"
|
||||||
| "layer"
|
| "layer"
|
||||||
| "projection"
|
| "projection"
|
||||||
| "relation"
|
| "relation"
|
||||||
@@ -364,13 +370,24 @@ fn enum_field(key: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse V2, or explicitly migrate a valid V1 source candidate. Parsing never
|
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never
|
||||||
/// adopts a model and never overwrites an editor draft.
|
/// adopts a model and never overwrites an editor draft.
|
||||||
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
||||||
parse_limited(source, ParseLimits::UNLIMITED)
|
parse_limited(source, ParseLimits::UNLIMITED)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<Diagnostic>> {
|
pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<Diagnostic>> {
|
||||||
|
parse_with_report_limited(source, limits).map(|decoded| decoded.project)
|
||||||
|
}
|
||||||
|
pub fn parse_with_report(
|
||||||
|
source: &str,
|
||||||
|
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
|
||||||
|
parse_with_report_limited(source, ParseLimits::UNLIMITED)
|
||||||
|
}
|
||||||
|
pub fn parse_with_report_limited(
|
||||||
|
source: &str,
|
||||||
|
limits: ParseLimits,
|
||||||
|
) -> Result<crate::persistence::DecodeResult, Vec<Diagnostic>> {
|
||||||
let parsed = parse_document(source, limits)?;
|
let parsed = parse_document(source, limits)?;
|
||||||
let errors: Vec<_> = parsed
|
let errors: Vec<_> = parsed
|
||||||
.diagnostics(source)
|
.diagnostics(source)
|
||||||
@@ -378,7 +395,10 @@ pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<D
|
|||||||
.filter(|d| d.severity == validation::Severity::Error)
|
.filter(|d| d.severity == validation::Severity::Error)
|
||||||
.collect();
|
.collect();
|
||||||
if errors.is_empty() {
|
if errors.is_empty() {
|
||||||
Ok(parsed.project)
|
Ok(crate::persistence::DecodeResult {
|
||||||
|
project: parsed.project,
|
||||||
|
migration: parsed.migration,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(errors)
|
Err(errors)
|
||||||
}
|
}
|
||||||
@@ -386,6 +406,7 @@ pub fn parse_limited(source: &str, limits: ParseLimits) -> Result<Project, Vec<D
|
|||||||
|
|
||||||
struct ParsedDocument {
|
struct ParsedDocument {
|
||||||
project: Project,
|
project: Project,
|
||||||
|
migration: Option<crate::persistence::MigrationReport>,
|
||||||
locations: BTreeMap<Id, usize>,
|
locations: BTreeMap<Id, usize>,
|
||||||
fields: BTreeMap<(Id, String), usize>,
|
fields: BTreeMap<(Id, String), usize>,
|
||||||
}
|
}
|
||||||
@@ -460,22 +481,31 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})?;
|
})?;
|
||||||
let project = crate::persistence::migrate_v1_limited(old, limits).map_err(|message| {
|
let decoded =
|
||||||
|
crate::persistence::migrate_v1_with_report_limited(old, limits).map_err(|error| {
|
||||||
vec![diagnostic(
|
vec![diagnostic(
|
||||||
source,
|
source,
|
||||||
Error {
|
Error {
|
||||||
code: "invalid_source",
|
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||||
|
"model_limit"
|
||||||
|
} else {
|
||||||
|
"invalid_source"
|
||||||
|
},
|
||||||
offset: 0,
|
offset: 0,
|
||||||
message,
|
message: error.to_string(),
|
||||||
},
|
},
|
||||||
)]
|
)]
|
||||||
})?;
|
})?;
|
||||||
return Ok(ParsedDocument {
|
return Ok(ParsedDocument {
|
||||||
project,
|
project: decoded.project,
|
||||||
|
migration: decoded.migration,
|
||||||
locations: BTreeMap::new(),
|
locations: BTreeMap::new(),
|
||||||
fields: BTreeMap::new(),
|
fields: BTreeMap::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
|
||||||
|
return v2_source::parse(source, tokens, limits);
|
||||||
|
}
|
||||||
Parser {
|
Parser {
|
||||||
source,
|
source,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -510,7 +540,7 @@ pub fn format(project: &Project) -> String {
|
|||||||
.map(|(id, a)| (id, a.to_owned()))
|
.map(|(id, a)| (id, a.to_owned()))
|
||||||
.collect::<BTreeMap<_, _>>();
|
.collect::<BTreeMap<_, _>>();
|
||||||
let mut out = std::format!(
|
let mut out = std::format!(
|
||||||
"arcadia 2;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||||
project.alias,
|
project.alias,
|
||||||
quote(&project.name),
|
quote(&project.name),
|
||||||
project.id,
|
project.id,
|
||||||
@@ -1000,8 +1030,8 @@ impl Parser<'_> {
|
|||||||
}
|
}
|
||||||
fn document(&mut self) -> Result<ParsedDocument, Error> {
|
fn document(&mut self) -> Result<ParsedDocument, Error> {
|
||||||
self.take("arcadia")?;
|
self.take("arcadia")?;
|
||||||
if self.text() != "2" {
|
if self.text() != "3" {
|
||||||
return Err(self.error("Unsupported schema version: expected 'arcadia 2;'."));
|
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
|
||||||
}
|
}
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
self.take(";")?;
|
self.take(";")?;
|
||||||
@@ -1116,7 +1146,22 @@ impl Parser<'_> {
|
|||||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||||
let parent =
|
let parent =
|
||||||
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
|
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
|
||||||
let mut initial = ElementBody::new(kind, layer).unwrap();
|
let mut initial = if kind == Kind::Port {
|
||||||
|
if !fields.contains_key("contract") {
|
||||||
|
return Err(conversion_error(
|
||||||
|
"Missing required field 'contract'.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ElementBody::shared_in(
|
||||||
|
layer,
|
||||||
|
SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ElementBody::new(kind, layer).unwrap()
|
||||||
|
};
|
||||||
// Schema-2 sources predate explicit scenario viewpoints.
|
// Schema-2 sources predate explicit scenario viewpoints.
|
||||||
// Preserve their mixed lifelines instead of reinterpreting
|
// Preserve their mixed lifelines instead of reinterpreting
|
||||||
// old source using a new graphical factory's typed default.
|
// old source using a new graphical factory's typed default.
|
||||||
@@ -1151,7 +1196,12 @@ impl Parser<'_> {
|
|||||||
DeclKind::Relation(kind) => {
|
DeclKind::Relation(kind) => {
|
||||||
let description =
|
let description =
|
||||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||||
for required in ["source", "target"] {
|
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||||
|
&["source", "target", "source_port", "target_port"]
|
||||||
|
} else {
|
||||||
|
&["source", "target"]
|
||||||
|
};
|
||||||
|
for &required in required {
|
||||||
if !fields.contains_key(required) {
|
if !fields.contains_key(required) {
|
||||||
return Err(conversion_error(std::format!(
|
return Err(conversion_error(std::format!(
|
||||||
"Missing required field '{required}'."
|
"Missing required field '{required}'."
|
||||||
@@ -1246,6 +1296,7 @@ impl Parser<'_> {
|
|||||||
})?;
|
})?;
|
||||||
Ok(ParsedDocument {
|
Ok(ParsedDocument {
|
||||||
project,
|
project,
|
||||||
|
migration: None,
|
||||||
locations,
|
locations,
|
||||||
fields: field_locations,
|
fields: field_locations,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Frozen version 2 reader
|
||||||
|
|
||||||
|
`model.rs`, `validation.rs`, `state_validation.rs`, `references.rs`, the drift
|
||||||
|
comparison in `transitions.rs`, and the typed declaration reader in `source.rs`
|
||||||
|
were captured from commit `c20a207` before the writable model changed to schema 3.
|
||||||
|
The modules are private; they are not another authoring model.
|
||||||
|
|
||||||
|
Imports point at the frozen modules, and the unused demo factory is excluded.
|
||||||
|
The declaration reader shares lexical/AST mechanics with the current language,
|
||||||
|
but constructs and validates the frozen DTO, preserves located old-contract
|
||||||
|
errors, then calls `adapter.rs`. Original staged scenario/state defaults and
|
||||||
|
generic port/optional FE endpoint contracts therefore remain explicit.
|
||||||
|
|
||||||
|
`adapter.rs` budgets generated data, validates V2, transforms only the changed
|
||||||
|
payloads, and validates schema 3. Serialization between the two typed DTOs copies
|
||||||
|
unchanged fields; original input bytes are never deserialized into the new
|
||||||
|
`Project`. V1 normalization also constructs this V2 DTO before the shared stage.
|
||||||
|
|
||||||
|
Do not update these frozen shapes when extending the current model. Add another
|
||||||
|
versioned migration when the persistent contract changes again. Golden V2
|
||||||
|
source/JSON fixtures remain unchanged.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
use crate::language::ParseLimits;
|
||||||
|
use crate::persistence::{
|
||||||
|
DecodeResult, FileError, GeneratedEndpoint, MigrationReport, OrientationDefault, PreservedPort,
|
||||||
|
};
|
||||||
|
use crate::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// Validate the real V2 graph, then explicitly transform its changed payloads.
|
||||||
|
/// The JSON bridge below copies unchanged DTO fields; it never reads old bytes
|
||||||
|
/// as a current Project and never lets current constructors supply old defaults.
|
||||||
|
pub(crate) fn migrate(
|
||||||
|
old: super::model::Project,
|
||||||
|
limits: ParseLimits,
|
||||||
|
) -> Result<DecodeResult, FileError> {
|
||||||
|
super::check_limits(&old, limits).map_err(FileError::Limit)?;
|
||||||
|
let mut generated = 0usize;
|
||||||
|
let mut additional_references = 0usize;
|
||||||
|
for r in old.relations.values() {
|
||||||
|
if let super::model::RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
items,
|
||||||
|
..
|
||||||
|
} = &r.body
|
||||||
|
{
|
||||||
|
let missing = usize::from(source_port.is_none()) + usize::from(target_port.is_none());
|
||||||
|
generated = generated.saturating_add(missing);
|
||||||
|
additional_references = additional_references
|
||||||
|
.saturating_add(missing.saturating_mul(items.len().saturating_add(2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (graph, records, references) = super::counts(&old);
|
||||||
|
limits
|
||||||
|
.check_counts(
|
||||||
|
graph.saturating_add(generated),
|
||||||
|
records.saturating_add(generated),
|
||||||
|
references.saturating_add(additional_references),
|
||||||
|
)
|
||||||
|
.map_err(FileError::Limit)?;
|
||||||
|
if let Some(issue) = super::validation::validate(&old)
|
||||||
|
.into_iter()
|
||||||
|
.find(|i| i.severity == super::validation::Severity::Error)
|
||||||
|
{
|
||||||
|
let message = format!(
|
||||||
|
"V2 {} at {}.{}: {}",
|
||||||
|
issue.code,
|
||||||
|
issue
|
||||||
|
.element
|
||||||
|
.map_or_else(|| "project".into(), |id| id.to_string()),
|
||||||
|
issue.field.as_deref().unwrap_or_default(),
|
||||||
|
issue.message
|
||||||
|
);
|
||||||
|
return Err(FileError::Model {
|
||||||
|
message,
|
||||||
|
issues: vec![validation::Issue {
|
||||||
|
severity: validation::Severity::Error,
|
||||||
|
element: issue.element,
|
||||||
|
code: issue.code,
|
||||||
|
field: issue.field,
|
||||||
|
message: issue.message,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut report = MigrationReport {
|
||||||
|
from_schema: 2,
|
||||||
|
to_schema: 3,
|
||||||
|
preserved_ports: vec![],
|
||||||
|
generated_endpoints: vec![],
|
||||||
|
orientation_defaults: vec![],
|
||||||
|
required_resolutions: vec![],
|
||||||
|
};
|
||||||
|
let mut value = serde_json::to_value(&old)?;
|
||||||
|
value["schema_version"] = Value::from(SCHEMA_VERSION);
|
||||||
|
for element in old.elements.values() {
|
||||||
|
if let Some(super::model::SharedBody::Port {
|
||||||
|
direction,
|
||||||
|
interface,
|
||||||
|
}) = element.shared()
|
||||||
|
{
|
||||||
|
let layer = match element.layer() {
|
||||||
|
super::model::Layer::Oa => "Oa",
|
||||||
|
super::model::Layer::Sa => "Sa",
|
||||||
|
};
|
||||||
|
value["elements"][element.id.to_string()]["body"][layer]["Shared"]["Port"] = serde_json::json!({"contract":"Legacy", "legacy":{"direction":direction,"interface":interface}});
|
||||||
|
let required_resolution = if element.layer() == super::model::Layer::Oa {
|
||||||
|
"Keep this OA Legacy contract unresolved or review an explicit SA counterpart; typed ports are SA-only."
|
||||||
|
} else {
|
||||||
|
"Review the preserved generic direction/interface contract before selecting a typed SA port contract."
|
||||||
|
};
|
||||||
|
report.preserved_ports.push(PreservedPort {
|
||||||
|
id: element.id,
|
||||||
|
alias: element.alias.clone(),
|
||||||
|
required_resolution: required_resolution.into(),
|
||||||
|
});
|
||||||
|
report.required_resolutions.push(element.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut used_ids: BTreeSet<_> = old.identities().into_iter().map(|(id, _)| id).collect();
|
||||||
|
// Use the existing alias convention against the old graph plus each newly
|
||||||
|
// planned endpoint, so cross-category alias collisions are deterministic.
|
||||||
|
let mut alias_project = old.clone();
|
||||||
|
for relation in old.relations.values() {
|
||||||
|
match &relation.body {
|
||||||
|
super::model::RelationBody::FunctionalExchange {
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
items,
|
||||||
|
} => {
|
||||||
|
for (side, owner, existing, output) in [
|
||||||
|
("source", *source, source_port, true),
|
||||||
|
("target", *target, target_port, false),
|
||||||
|
] {
|
||||||
|
if existing.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = Id::new_v5(
|
||||||
|
&old.id,
|
||||||
|
format!("v2-fe-port:{}:{side}", relation.id).as_bytes(),
|
||||||
|
);
|
||||||
|
if !used_ids.insert(id) {
|
||||||
|
let message = format!(
|
||||||
|
"V2 migration identity_collision at {}.{}_port: deterministic endpoint {} already belongs to another object.",
|
||||||
|
relation.id, side, id
|
||||||
|
);
|
||||||
|
return Err(FileError::Model {
|
||||||
|
message: message.clone(),
|
||||||
|
issues: vec![validation::Issue {
|
||||||
|
severity: validation::Severity::Error,
|
||||||
|
element: Some(relation.id),
|
||||||
|
code: "identity_collision".into(),
|
||||||
|
field: Some(format!("{side}_port")),
|
||||||
|
message,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let role = if output { "output" } else { "input" };
|
||||||
|
let name = if relation.name.trim().is_empty() {
|
||||||
|
format!("Function {role}")
|
||||||
|
} else {
|
||||||
|
format!("{} {role}", relation.name)
|
||||||
|
};
|
||||||
|
let alias = alias_project.fresh_alias(&name, "port", id);
|
||||||
|
// Only identity/name/alias enter this planning-only V2 map.
|
||||||
|
alias_project.elements.insert(
|
||||||
|
id,
|
||||||
|
super::model::Element {
|
||||||
|
id,
|
||||||
|
alias: alias.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
description: String::new(),
|
||||||
|
parent: Some(owner),
|
||||||
|
body: super::model::ElementBody::Sa(super::model::SaBody::Function),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let contract = if output {
|
||||||
|
PortContract::FunctionOutput {
|
||||||
|
outgoing_items: items.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PortContract::FunctionInput {
|
||||||
|
incoming_items: items.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let element = Element {
|
||||||
|
id,
|
||||||
|
alias: alias.clone(),
|
||||||
|
name,
|
||||||
|
description: String::new(),
|
||||||
|
parent: Some(owner),
|
||||||
|
body: ElementBody::shared_in(
|
||||||
|
Layer::Sa,
|
||||||
|
SharedBody::Port {
|
||||||
|
contract,
|
||||||
|
legacy: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
value["elements"]
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("V2 elements map")
|
||||||
|
.insert(id.to_string(), serde_json::to_value(element)?);
|
||||||
|
value["relations"][relation.id.to_string()]["body"]["FunctionalExchange"]
|
||||||
|
[format!("{side}_port")] = serde_json::to_value(id)?;
|
||||||
|
report.generated_endpoints.push(GeneratedEndpoint {
|
||||||
|
exchange: relation.id,
|
||||||
|
side: side.into(),
|
||||||
|
port: id,
|
||||||
|
owner,
|
||||||
|
alias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super::model::RelationBody::BoundaryExchange { .. } => {
|
||||||
|
value["relations"][relation.id.to_string()]["body"]["BoundaryExchange"]["oriented"] =
|
||||||
|
Value::Bool(true);
|
||||||
|
report.orientation_defaults.push(OrientationDefault {
|
||||||
|
exchange: relation.id,
|
||||||
|
oriented: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let project: Project = serde_json::from_value(value)?;
|
||||||
|
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||||
|
super::super::validate_project(&project)?;
|
||||||
|
Ok(DecodeResult {
|
||||||
|
project,
|
||||||
|
migration: Some(report),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//! Version-2 DTO and validation snapshot. Kept separate from the writable model
|
||||||
|
//! so accepting an old file cannot accidentally inherit version-3 defaults.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
pub(crate) mod adapter;
|
||||||
|
pub(crate) mod model;
|
||||||
|
mod references;
|
||||||
|
mod state_validation;
|
||||||
|
mod transitions;
|
||||||
|
pub(crate) mod validation;
|
||||||
|
|
||||||
|
pub(crate) fn counts(project: &model::Project) -> (usize, usize, usize) {
|
||||||
|
let graph = project
|
||||||
|
.elements
|
||||||
|
.len()
|
||||||
|
.saturating_add(project.relations.len());
|
||||||
|
let records = graph.saturating_add(project.identities().len().saturating_sub(graph));
|
||||||
|
// Keep the established ordered-reference budget for original records.
|
||||||
|
// The adapter separately charges every reference it would generate.
|
||||||
|
let references = project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.map(|e| e.steps().map_or(0, Vec::len))
|
||||||
|
.fold(0usize, usize::saturating_add);
|
||||||
|
(graph, records, references)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn check_limits(
|
||||||
|
project: &model::Project,
|
||||||
|
limits: crate::language::ParseLimits,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (graph, records, references) = counts(project);
|
||||||
|
limits.check_counts(graph, records, references)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
|||||||
|
// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3.
|
||||||
|
use super::model::*;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// Every live reference in a typed element, including structural ownership.
|
||||||
|
pub fn element_references(e: &Element) -> Vec<Id> {
|
||||||
|
let mut copy = e.clone();
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
visit_element_refs(&mut copy, |id| refs.push(*id));
|
||||||
|
refs
|
||||||
|
}
|
||||||
|
pub fn relation_references(r: &Relation) -> Vec<Id> {
|
||||||
|
let mut copy = r.clone();
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
visit_relation_refs(&mut copy, |id| refs.push(*id));
|
||||||
|
refs
|
||||||
|
}
|
||||||
|
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||||
|
if let Some(parent) = &mut e.parent {
|
||||||
|
visit(parent);
|
||||||
|
}
|
||||||
|
match &mut e.body {
|
||||||
|
ElementBody::Oa(OaBody::Process { steps })
|
||||||
|
| ElementBody::Sa(SaBody::FunctionalChain { steps }) => {
|
||||||
|
for id in steps {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(shared) = e.shared_mut() {
|
||||||
|
match shared {
|
||||||
|
SharedBody::DataField {
|
||||||
|
type_ref: TypeRef::Named(id),
|
||||||
|
..
|
||||||
|
} => visit(id),
|
||||||
|
SharedBody::Interface { items } => {
|
||||||
|
for id in items {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedBody::Event {
|
||||||
|
body: EventBody::Signal { items },
|
||||||
|
} => {
|
||||||
|
for id in items {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedBody::Port {
|
||||||
|
interface: Some(id),
|
||||||
|
..
|
||||||
|
} => visit(id),
|
||||||
|
SharedBody::Scenario { context, steps, .. } => {
|
||||||
|
if let Some(id) = context {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
for id in steps {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SharedBody::Lifeline {
|
||||||
|
represented: Some(id),
|
||||||
|
} => visit(id),
|
||||||
|
SharedBody::Occurrence { body, .. } => match body {
|
||||||
|
OccurrenceBody::Exchange {
|
||||||
|
exchange,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
reply_to,
|
||||||
|
values,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
for value in values {
|
||||||
|
visit(&mut value.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OccurrenceBody::Action { action, lifeline } => {
|
||||||
|
for id in [action, lifeline].into_iter().flatten() {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OccurrenceBody::SequenceReference { sequence } => {
|
||||||
|
if let Some(id) = sequence {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OccurrenceBody::Control { .. } => {}
|
||||||
|
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||||
|
if let Some(id) = scenario {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
for binding in bindings {
|
||||||
|
visit(&mut binding.referenced);
|
||||||
|
visit(&mut binding.local);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OccurrenceBody::State { state, lifeline } => {
|
||||||
|
for id in [state, lifeline].into_iter().flatten() {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OccurrenceBody::Fragment { branches, .. } => {
|
||||||
|
for id in branches {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OccurrenceBody::Branch { occurrences, .. } => {
|
||||||
|
for id in occurrences {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SharedBody::Constraint { constrained, .. } => {
|
||||||
|
for id in constrained {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||||
|
let (mut source, mut target) = r.body.endpoints();
|
||||||
|
visit(&mut source);
|
||||||
|
visit(&mut target);
|
||||||
|
r.body.set_endpoints(source, target);
|
||||||
|
match &mut r.body {
|
||||||
|
RelationBody::StateTransition {
|
||||||
|
triggers, effects, ..
|
||||||
|
} => {
|
||||||
|
for id in triggers.iter_mut().chain(effects) {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
|
||||||
|
for id in items {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelationBody::FunctionalExchange {
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
items,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
for id in [source_port, target_port].into_iter().flatten() {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
for id in items {
|
||||||
|
visit(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||||
|
for e in p.elements.values_mut() {
|
||||||
|
visit_element_refs(e, |id| {
|
||||||
|
if *id == old {
|
||||||
|
*id = new;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for r in p.relations.values_mut() {
|
||||||
|
visit_relation_refs(r, |id| {
|
||||||
|
if *id == old {
|
||||||
|
*id = new;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for d in p.diagrams.values_mut() {
|
||||||
|
for n in d.nodes.values_mut() {
|
||||||
|
if n.element == old {
|
||||||
|
n.element = new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for a in p.artifact_links.values_mut() {
|
||||||
|
if a.owner == old {
|
||||||
|
a.owner = new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in p.traces.values_mut() {
|
||||||
|
if t.oa == old {
|
||||||
|
t.oa = new;
|
||||||
|
}
|
||||||
|
if t.sa == old {
|
||||||
|
t.sa = new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||||
|
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||||
|
let mut removed = BTreeSet::from([id]);
|
||||||
|
loop {
|
||||||
|
let before = removed.len();
|
||||||
|
for e in p.elements.values() {
|
||||||
|
let dependent_type = matches!(e.shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(t),..}) if removed.contains(t));
|
||||||
|
if e.parent.is_some_and(|parent| removed.contains(&parent)) || dependent_type {
|
||||||
|
removed.insert(e.id);
|
||||||
|
}
|
||||||
|
if matches!(e.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(r),..},..}) if removed.contains(r))
|
||||||
|
{
|
||||||
|
removed.insert(e.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r in p.relations.values() {
|
||||||
|
if removed.contains(&r.source()) || removed.contains(&r.target()) {
|
||||||
|
removed.insert(r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for d in p.diagrams.values() {
|
||||||
|
for n in d.nodes.values() {
|
||||||
|
if removed.contains(&d.id) || removed.contains(&n.element) {
|
||||||
|
removed.insert(n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in p.traces.values() {
|
||||||
|
if removed.contains(&t.oa) || removed.contains(&t.sa) {
|
||||||
|
removed.insert(t.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for a in p.artifact_links.values() {
|
||||||
|
if removed.contains(&a.owner) {
|
||||||
|
removed.insert(a.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if removed.len() == before {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
pub fn delete_cascade(p: &mut Project, id: Id) {
|
||||||
|
let removed = deletion_impact(p, id);
|
||||||
|
let removed_names: std::collections::BTreeMap<_, _> = removed
|
||||||
|
.iter()
|
||||||
|
.map(|id| (*id, p.named(*id).to_string()))
|
||||||
|
.collect();
|
||||||
|
p.elements.retain(|id, _| !removed.contains(id));
|
||||||
|
p.relations.retain(|id, _| !removed.contains(id));
|
||||||
|
p.diagrams.retain(|id, _| !removed.contains(id));
|
||||||
|
p.traces.retain(|id, _| !removed.contains(id));
|
||||||
|
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||||
|
for d in p.diagrams.values_mut() {
|
||||||
|
d.nodes.retain(|id, _| !removed.contains(id));
|
||||||
|
}
|
||||||
|
let clear = |v: &mut Option<Id>| {
|
||||||
|
if v.is_some_and(|id| removed.contains(&id)) {
|
||||||
|
*v = None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for e in p.elements.values_mut() {
|
||||||
|
if let Some(steps) = e.steps_mut() {
|
||||||
|
steps.retain(|id| !removed.contains(id));
|
||||||
|
}
|
||||||
|
if let Some(shared) = e.shared_mut() {
|
||||||
|
match shared {
|
||||||
|
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
|
||||||
|
SharedBody::Event {
|
||||||
|
body: EventBody::Signal { items },
|
||||||
|
} => items.retain(|id| !removed.contains(id)),
|
||||||
|
SharedBody::Port { interface, .. } => clear(interface),
|
||||||
|
SharedBody::Scenario { context, .. } => clear(context),
|
||||||
|
SharedBody::Lifeline { represented } => clear(represented),
|
||||||
|
SharedBody::Constraint { constrained, .. } => {
|
||||||
|
constrained.retain(|id| !removed.contains(id))
|
||||||
|
}
|
||||||
|
SharedBody::Occurrence { body, .. } => match body {
|
||||||
|
OccurrenceBody::Exchange {
|
||||||
|
exchange,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
reply_to,
|
||||||
|
values,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
clear(exchange);
|
||||||
|
clear(sender);
|
||||||
|
clear(receiver);
|
||||||
|
clear(reply_to);
|
||||||
|
values.retain(|value| !removed.contains(&value.field));
|
||||||
|
}
|
||||||
|
OccurrenceBody::Action { action, lifeline } => {
|
||||||
|
clear(action);
|
||||||
|
clear(lifeline);
|
||||||
|
}
|
||||||
|
OccurrenceBody::SequenceReference { sequence } => clear(sequence),
|
||||||
|
OccurrenceBody::Control { .. } => {}
|
||||||
|
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||||
|
clear(scenario);
|
||||||
|
bindings.retain(|binding| {
|
||||||
|
!removed.contains(&binding.referenced)
|
||||||
|
&& !removed.contains(&binding.local)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
OccurrenceBody::State { state, lifeline } => {
|
||||||
|
clear(state);
|
||||||
|
clear(lifeline);
|
||||||
|
}
|
||||||
|
OccurrenceBody::Fragment { branches, .. } => {
|
||||||
|
branches.retain(|id| !removed.contains(id))
|
||||||
|
}
|
||||||
|
OccurrenceBody::Branch { .. } => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r in p.relations.values_mut() {
|
||||||
|
match &mut r.body {
|
||||||
|
RelationBody::StateTransition {
|
||||||
|
triggers,
|
||||||
|
effects,
|
||||||
|
trigger,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let deleted: Vec<_> = triggers
|
||||||
|
.iter()
|
||||||
|
.filter(|id| removed.contains(id))
|
||||||
|
.filter_map(|id| removed_names.get(id).cloned())
|
||||||
|
.collect();
|
||||||
|
triggers.retain(|id| !removed.contains(id));
|
||||||
|
effects.retain(|id| !removed.contains(id));
|
||||||
|
if !deleted.is_empty() && triggers.is_empty() && trigger.trim().is_empty() {
|
||||||
|
*trigger = format!(
|
||||||
|
"Removed trigger: {}. Choose a replacement trigger.",
|
||||||
|
deleted.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RelationBody::Interaction { items, .. }
|
||||||
|
| RelationBody::BoundaryExchange { items, .. } => {
|
||||||
|
items.retain(|id| !removed.contains(id))
|
||||||
|
}
|
||||||
|
RelationBody::FunctionalExchange {
|
||||||
|
items,
|
||||||
|
source_port,
|
||||||
|
target_port,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
items.retain(|id| !removed.contains(id));
|
||||||
|
clear(source_port);
|
||||||
|
clear(target_port);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Removing an item or a containing payload field can disconnect a still-live
|
||||||
|
// record field from an exchange. Its contextual annotation is no longer valid.
|
||||||
|
let allowed: std::collections::BTreeMap<_, _> = p
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.filter_map(|e| match e.shared() {
|
||||||
|
Some(SharedBody::Occurrence {
|
||||||
|
body:
|
||||||
|
OccurrenceBody::Exchange {
|
||||||
|
exchange: Some(id), ..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
}) => p
|
||||||
|
.relations
|
||||||
|
.get(id)
|
||||||
|
.map(|r| (e.id, super::validation::carried_fields(p, r))),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (id, fields) in allowed {
|
||||||
|
if let Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange { values, .. },
|
||||||
|
..
|
||||||
|
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
|
||||||
|
{
|
||||||
|
values.retain(|value| fields.contains(&value.field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
//! Frozen schema-2 declaration reader from c20a207, with explicit migration and located diagnostics.
|
||||||
|
use super::{
|
||||||
|
Ast, Error, Lexeme, ParseLimits, TokenClass, merge_fields, payload, replace_payload,
|
||||||
|
take_optional_id, take_required_id, take_string,
|
||||||
|
};
|
||||||
|
use crate::persistence::v2::model::*;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum DeclKind {
|
||||||
|
Element(Kind, Layer),
|
||||||
|
Relation(RelationKind),
|
||||||
|
Diagram,
|
||||||
|
Node,
|
||||||
|
Artifact,
|
||||||
|
Trace,
|
||||||
|
}
|
||||||
|
fn snake(value: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in value.chars().enumerate() {
|
||||||
|
if c.is_ascii_uppercase() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push('_');
|
||||||
|
}
|
||||||
|
out.push(c.to_ascii_lowercase());
|
||||||
|
} else {
|
||||||
|
out.push(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
fn element_word(kind: Kind, layer: Layer) -> String {
|
||||||
|
let stem = match kind {
|
||||||
|
Kind::SystemActor => "actor".into(),
|
||||||
|
Kind::SystemCapability => "capability".into(),
|
||||||
|
Kind::OperationalMission => "mission".into(),
|
||||||
|
_ => snake(serde_json::to_value(kind).unwrap().as_str().unwrap()),
|
||||||
|
};
|
||||||
|
std::format!("{}_{stem}", if layer == Layer::Oa { "oa" } else { "sa" })
|
||||||
|
}
|
||||||
|
fn relation_word(kind: RelationKind) -> String {
|
||||||
|
snake(serde_json::to_value(kind).unwrap().as_str().unwrap())
|
||||||
|
}
|
||||||
|
fn declaration_kind(word: &str) -> Option<DeclKind> {
|
||||||
|
static KINDS: std::sync::OnceLock<BTreeMap<String, DeclKind>> = std::sync::OnceLock::new();
|
||||||
|
KINDS
|
||||||
|
.get_or_init(|| {
|
||||||
|
let mut kinds = BTreeMap::from([
|
||||||
|
("diagram".into(), DeclKind::Diagram),
|
||||||
|
("node".into(), DeclKind::Node),
|
||||||
|
("artifact".into(), DeclKind::Artifact),
|
||||||
|
("trace".into(), DeclKind::Trace),
|
||||||
|
]);
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
for kind in Kind::ALL {
|
||||||
|
if kind.supports(layer) {
|
||||||
|
kinds.insert(element_word(kind, layer), DeclKind::Element(kind, layer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for kind in RelationKind::ALL {
|
||||||
|
kinds.insert(relation_word(kind), DeclKind::Relation(kind));
|
||||||
|
}
|
||||||
|
kinds
|
||||||
|
})
|
||||||
|
.get(word)
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
struct Declaration {
|
||||||
|
kind: DeclKind,
|
||||||
|
alias: String,
|
||||||
|
name: String,
|
||||||
|
id: Id,
|
||||||
|
fields: BTreeMap<String, Ast>,
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
struct Parser2<'a> {
|
||||||
|
source: &'a str,
|
||||||
|
tokens: Vec<Lexeme>,
|
||||||
|
cursor: usize,
|
||||||
|
value_depth: usize,
|
||||||
|
limits: ParseLimits,
|
||||||
|
semantic_error: Option<crate::validation::Issue>,
|
||||||
|
}
|
||||||
|
impl Parser2<'_> {
|
||||||
|
fn current(&self) -> Lexeme {
|
||||||
|
self.tokens[self.cursor.min(self.tokens.len() - 1)]
|
||||||
|
}
|
||||||
|
fn text(&self) -> &str {
|
||||||
|
self.current().text(self.source)
|
||||||
|
}
|
||||||
|
fn error(&self, message: impl Into<String>) -> Error {
|
||||||
|
Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset: self.current().span.start,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn take(&mut self, word: &str) -> Result<(), Error> {
|
||||||
|
if self.text() != word {
|
||||||
|
return Err(self.error(std::format!("Expected '{word}'.")));
|
||||||
|
}
|
||||||
|
self.cursor += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn quoted(&mut self) -> Result<String, Error> {
|
||||||
|
if self.current().class != TokenClass::String {
|
||||||
|
return Err(self.error("Expected a double-quoted string."));
|
||||||
|
}
|
||||||
|
let value = serde_json::from_str(self.text()).unwrap();
|
||||||
|
self.cursor += 1;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
fn identifier(&mut self) -> Result<String, Error> {
|
||||||
|
let value = self.text().to_owned();
|
||||||
|
if !valid_alias(&value) {
|
||||||
|
return Err(self.error(std::format!("Invalid or reserved alias '{value}'.")));
|
||||||
|
}
|
||||||
|
self.cursor += 1;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
fn optional_id(&mut self) -> Result<Id, Error> {
|
||||||
|
if self.text() != "id" {
|
||||||
|
return Ok(Id::new_v4());
|
||||||
|
}
|
||||||
|
self.cursor += 1;
|
||||||
|
let offset = self.current().span.start;
|
||||||
|
let value = self.quoted()?;
|
||||||
|
if value.len() != 36
|
||||||
|
|| ![8, 13, 18, 23]
|
||||||
|
.into_iter()
|
||||||
|
.all(|i| value.as_bytes()[i] == b'-')
|
||||||
|
{
|
||||||
|
return Err(Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset,
|
||||||
|
message: "Expected UUID in 8-4-4-4-12 hexadecimal form.".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Id::parse_str(&value).map_err(|_| Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset,
|
||||||
|
message: "Invalid UUID identity.".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn fields(&mut self) -> Result<BTreeMap<String, Ast>, Error> {
|
||||||
|
self.take("{")?;
|
||||||
|
let mut fields = BTreeMap::new();
|
||||||
|
while self.text() != "}" {
|
||||||
|
let key = self.text().to_owned();
|
||||||
|
let offset = self.current().span.start;
|
||||||
|
if !matches!(
|
||||||
|
self.current().class,
|
||||||
|
TokenClass::Keyword | TokenClass::Identifier
|
||||||
|
) {
|
||||||
|
return Err(self.error("Expected a field name or '}'."));
|
||||||
|
}
|
||||||
|
self.cursor += 1;
|
||||||
|
let value = self.value()?;
|
||||||
|
self.take(";")?;
|
||||||
|
if fields.insert(key.clone(), value).is_some() {
|
||||||
|
return Err(Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset,
|
||||||
|
message: std::format!("Duplicate field '{key}'."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.take("}")?;
|
||||||
|
Ok(fields)
|
||||||
|
}
|
||||||
|
fn value(&mut self) -> Result<Ast, Error> {
|
||||||
|
if self.value_depth >= 64 {
|
||||||
|
return Err(self.error("Typed values may not nest more than 64 levels."));
|
||||||
|
}
|
||||||
|
self.value_depth += 1;
|
||||||
|
let result = self.value_inner();
|
||||||
|
self.value_depth -= 1;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn value_inner(&mut self) -> Result<Ast, Error> {
|
||||||
|
let token = self.current();
|
||||||
|
let offset = token.span.start;
|
||||||
|
match self.text() {
|
||||||
|
"@" => {
|
||||||
|
self.cursor += 1;
|
||||||
|
Ok(Ast::Reference(self.identifier()?, offset))
|
||||||
|
}
|
||||||
|
"{" => Ok(Ast::Object(self.fields()?, offset)),
|
||||||
|
"[" => {
|
||||||
|
self.cursor += 1;
|
||||||
|
let mut values = Vec::new();
|
||||||
|
if self.text() != "]" {
|
||||||
|
loop {
|
||||||
|
values.push(self.value()?);
|
||||||
|
if self.text() != "," {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.take("]")?;
|
||||||
|
Ok(Ast::Array(values, offset))
|
||||||
|
}
|
||||||
|
"none" => {
|
||||||
|
self.cursor += 1;
|
||||||
|
Ok(Ast::Literal(Value::Null, offset))
|
||||||
|
}
|
||||||
|
"true" | "false" => {
|
||||||
|
let value = self.text() == "true";
|
||||||
|
self.cursor += 1;
|
||||||
|
Ok(Ast::Literal(Value::Bool(value), offset))
|
||||||
|
}
|
||||||
|
_ => match token.class {
|
||||||
|
TokenClass::String => Ok(Ast::Literal(Value::String(self.quoted()?), offset)),
|
||||||
|
TokenClass::Number => {
|
||||||
|
let value = serde_json::from_str(self.text())
|
||||||
|
.map_err(|_| self.error("Expected a finite JSON decimal number."))?;
|
||||||
|
self.cursor += 1;
|
||||||
|
Ok(Ast::Literal(value, offset))
|
||||||
|
}
|
||||||
|
TokenClass::Identifier | TokenClass::Keyword => {
|
||||||
|
let value = self.text().to_owned();
|
||||||
|
self.cursor += 1;
|
||||||
|
Ok(Ast::Word(value, offset))
|
||||||
|
}
|
||||||
|
_ => Err(self.error("Expected a value, @alias reference, list, or typed record.")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn document(&mut self) -> Result<super::ParsedDocument, Error> {
|
||||||
|
self.take("arcadia")?;
|
||||||
|
if self.text() != "2" {
|
||||||
|
return Err(self.error("Unsupported schema version: expected 'arcadia 2;'."));
|
||||||
|
}
|
||||||
|
self.cursor += 1;
|
||||||
|
self.take(";")?;
|
||||||
|
self.take("project")?;
|
||||||
|
let alias = self.identifier()?;
|
||||||
|
let name = self.quoted()?;
|
||||||
|
let id = self.optional_id()?;
|
||||||
|
self.take("{")?;
|
||||||
|
let mut project = Project {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
id,
|
||||||
|
alias: alias.clone(),
|
||||||
|
name,
|
||||||
|
description: String::new(),
|
||||||
|
elements: BTreeMap::new(),
|
||||||
|
relations: BTreeMap::new(),
|
||||||
|
diagrams: BTreeMap::new(),
|
||||||
|
artifact_links: BTreeMap::new(),
|
||||||
|
traces: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let mut aliases = BTreeMap::from([(alias, id)]);
|
||||||
|
let mut locations = BTreeMap::from([(id, 0)]);
|
||||||
|
let mut declarations = Vec::new();
|
||||||
|
let mut graph_count = 0usize;
|
||||||
|
let mut field_locations = BTreeMap::new();
|
||||||
|
let mut has_description = false;
|
||||||
|
while self.text() != "}" {
|
||||||
|
if self.text() == "description" {
|
||||||
|
if has_description {
|
||||||
|
return Err(self.error("Duplicate project description."));
|
||||||
|
}
|
||||||
|
has_description = true;
|
||||||
|
self.cursor += 1;
|
||||||
|
project.description = self.quoted()?;
|
||||||
|
self.take(";")?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let offset = self.current().span.start;
|
||||||
|
let word = self.text().to_owned();
|
||||||
|
let kind = declaration_kind(&word)
|
||||||
|
.ok_or_else(|| self.error(std::format!("Unknown declaration '{word}'.")))?;
|
||||||
|
graph_count += usize::from(matches!(
|
||||||
|
kind,
|
||||||
|
DeclKind::Element(..) | DeclKind::Relation(..)
|
||||||
|
));
|
||||||
|
self.limits
|
||||||
|
.check_counts(graph_count, declarations.len().saturating_add(2), 0)
|
||||||
|
.map_err(|message| Error {
|
||||||
|
code: "model_limit",
|
||||||
|
offset: self.current().span.start,
|
||||||
|
message,
|
||||||
|
})?;
|
||||||
|
self.cursor += 1;
|
||||||
|
let alias = self.identifier()?;
|
||||||
|
let name = if matches!(kind, DeclKind::Node) {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
self.quoted()?
|
||||||
|
};
|
||||||
|
let id = self.optional_id()?;
|
||||||
|
let fields = self.fields()?;
|
||||||
|
for (field, value) in &fields {
|
||||||
|
field_locations.insert((id, field.clone()), value.offset());
|
||||||
|
}
|
||||||
|
if aliases.insert(alias.clone(), id).is_some() {
|
||||||
|
return Err(Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset,
|
||||||
|
message: std::format!("Duplicate alias '{alias}'."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if locations.insert(id, offset).is_some() {
|
||||||
|
return Err(Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset,
|
||||||
|
message: std::format!("Duplicate identity '{id}'."),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
declarations.push(Declaration {
|
||||||
|
kind,
|
||||||
|
alias,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
fields,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.take("}")?;
|
||||||
|
if !self.text().is_empty() {
|
||||||
|
return Err(self.error("Unexpected content after the project closing brace."));
|
||||||
|
}
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
for declaration in declarations {
|
||||||
|
let mut fields = declaration
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| Ok((k.clone(), v.resolve(k, &aliases)?)))
|
||||||
|
.collect::<Result<Map<_, _>, Error>>()?;
|
||||||
|
let d = &declaration;
|
||||||
|
let conversion_error = |message: String| Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset: d
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.find(|(field, _)| message.contains(&std::format!("'{field}'")))
|
||||||
|
.map_or(d.offset, |(_, value)| value.offset()),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
match d.kind {
|
||||||
|
DeclKind::Element(kind, layer) => {
|
||||||
|
let description =
|
||||||
|
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||||
|
let parent =
|
||||||
|
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
|
||||||
|
let mut initial = ElementBody::new(kind, layer).unwrap();
|
||||||
|
// Schema-2 sources predate explicit scenario viewpoints.
|
||||||
|
// Preserve their mixed lifelines instead of reinterpreting
|
||||||
|
// old source using a new graphical factory's typed default.
|
||||||
|
if !fields.contains_key("viewpoint")
|
||||||
|
&& let Some(SharedBody::Scenario { viewpoint, .. }) = initial.shared_mut()
|
||||||
|
{
|
||||||
|
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||||
|
}
|
||||||
|
let mut body = serde_json::to_value(initial).unwrap();
|
||||||
|
let body_fields =
|
||||||
|
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||||
|
replace_payload(&mut body, body_fields);
|
||||||
|
let body: ElementBody = serde_json::from_value(body)
|
||||||
|
.map_err(|e| conversion_error(e.to_string()))?;
|
||||||
|
if body.kind() != kind || body.layer() != layer {
|
||||||
|
return Err(conversion_error(
|
||||||
|
"Declaration kind disagrees with its typed body.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
project.elements.insert(
|
||||||
|
d.id,
|
||||||
|
Element {
|
||||||
|
id: d.id,
|
||||||
|
alias: d.alias.clone(),
|
||||||
|
name: d.name.clone(),
|
||||||
|
description,
|
||||||
|
parent,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DeclKind::Relation(kind) => {
|
||||||
|
let description =
|
||||||
|
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||||
|
for required in ["source", "target"] {
|
||||||
|
if !fields.contains_key(required) {
|
||||||
|
return Err(conversion_error(std::format!(
|
||||||
|
"Missing required field '{required}'."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut body =
|
||||||
|
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
|
||||||
|
.unwrap();
|
||||||
|
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||||
|
replace_payload(&mut body, merged);
|
||||||
|
let body = serde_json::from_value(body)
|
||||||
|
.map_err(|e| conversion_error(e.to_string()))?;
|
||||||
|
project.relations.insert(
|
||||||
|
d.id,
|
||||||
|
Relation {
|
||||||
|
id: d.id,
|
||||||
|
alias: d.alias.clone(),
|
||||||
|
name: d.name.clone(),
|
||||||
|
description,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DeclKind::Diagram => {
|
||||||
|
let defaults =
|
||||||
|
serde_json::json!({"description":"","layer":"Oa","projection":"Overview"});
|
||||||
|
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||||
|
.map_err(&conversion_error)?;
|
||||||
|
header(&mut record, d, true);
|
||||||
|
record.insert("nodes".into(), serde_json::json!({}));
|
||||||
|
let diagram: Diagram = serde_json::from_value(Value::Object(record))
|
||||||
|
.map_err(|e| conversion_error(e.to_string()))?;
|
||||||
|
project.diagrams.insert(d.id, diagram);
|
||||||
|
}
|
||||||
|
DeclKind::Node => {
|
||||||
|
let diagram =
|
||||||
|
take_required_id(&mut fields, "diagram").map_err(&conversion_error)?;
|
||||||
|
if !fields.contains_key("element") {
|
||||||
|
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||||
|
}
|
||||||
|
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
|
||||||
|
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||||
|
.map_err(&conversion_error)?;
|
||||||
|
header(&mut record, d, false);
|
||||||
|
let node: NodePlacement = serde_json::from_value(Value::Object(record))
|
||||||
|
.map_err(|e| conversion_error(e.to_string()))?;
|
||||||
|
nodes.push((diagram, node, d.offset));
|
||||||
|
}
|
||||||
|
DeclKind::Artifact => {
|
||||||
|
if !fields.contains_key("owner") {
|
||||||
|
return Err(conversion_error("Missing required field 'owner'.".into()));
|
||||||
|
}
|
||||||
|
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
|
||||||
|
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||||
|
.map_err(&conversion_error)?;
|
||||||
|
header(&mut record, d, true);
|
||||||
|
let artifact = serde_json::from_value(Value::Object(record))
|
||||||
|
.map_err(|e| conversion_error(e.to_string()))?;
|
||||||
|
project.artifact_links.insert(d.id, artifact);
|
||||||
|
}
|
||||||
|
DeclKind::Trace => {
|
||||||
|
for required in ["oa", "sa"] {
|
||||||
|
if !fields.contains_key(required) {
|
||||||
|
return Err(conversion_error(std::format!(
|
||||||
|
"Missing required field '{required}'."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
|
||||||
|
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||||
|
.map_err(&conversion_error)?;
|
||||||
|
header(&mut record, d, true);
|
||||||
|
let trace = serde_json::from_value(Value::Object(record))
|
||||||
|
.map_err(|e| conversion_error(e.to_string()))?;
|
||||||
|
project.traces.insert(d.id, trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (diagram, node, offset) in nodes {
|
||||||
|
let target = project.diagrams.get_mut(&diagram).ok_or_else(|| Error {
|
||||||
|
code: "invalid_source",
|
||||||
|
offset,
|
||||||
|
message: "Node 'diagram' must reference a diagram declaration.".into(),
|
||||||
|
})?;
|
||||||
|
target.nodes.insert(node.id, node);
|
||||||
|
}
|
||||||
|
crate::persistence::v2::check_limits(&project, self.limits).map_err(|message| Error {
|
||||||
|
code: "model_limit",
|
||||||
|
offset: 0,
|
||||||
|
message,
|
||||||
|
})?;
|
||||||
|
let decoded =
|
||||||
|
crate::persistence::migrate_v2_limited(project, self.limits).map_err(|error| {
|
||||||
|
let offset = match &error {
|
||||||
|
crate::persistence::FileError::Model { issues, .. } => {
|
||||||
|
self.semantic_error = issues.first().cloned();
|
||||||
|
issues
|
||||||
|
.first()
|
||||||
|
.and_then(|issue| {
|
||||||
|
issue.element.and_then(|id| {
|
||||||
|
issue
|
||||||
|
.field
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|field| field_locations.get(&(id, field.clone())))
|
||||||
|
.or_else(|| locations.get(&id))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
Error {
|
||||||
|
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||||
|
"model_limit"
|
||||||
|
} else {
|
||||||
|
"invalid_source"
|
||||||
|
},
|
||||||
|
offset,
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(super::ParsedDocument {
|
||||||
|
project: decoded.project,
|
||||||
|
migration: decoded.migration,
|
||||||
|
locations,
|
||||||
|
fields: field_locations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
|
||||||
|
fields.insert("id".into(), Value::String(d.id.to_string()));
|
||||||
|
fields.insert("alias".into(), Value::String(d.alias.clone()));
|
||||||
|
if named {
|
||||||
|
fields.insert("name".into(), Value::String(d.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse(
|
||||||
|
source: &str,
|
||||||
|
tokens: Vec<Lexeme>,
|
||||||
|
limits: ParseLimits,
|
||||||
|
) -> Result<super::ParsedDocument, Vec<super::Diagnostic>> {
|
||||||
|
let mut parser = Parser2 {
|
||||||
|
source,
|
||||||
|
tokens,
|
||||||
|
cursor: 0,
|
||||||
|
value_depth: 0,
|
||||||
|
limits,
|
||||||
|
semantic_error: None,
|
||||||
|
};
|
||||||
|
parser.document().map_err(|error| {
|
||||||
|
let mut diagnostic = super::diagnostic(source, error);
|
||||||
|
if let Some(issue) = parser.semantic_error {
|
||||||
|
diagnostic.code = issue.code;
|
||||||
|
diagnostic.object_id = issue.element;
|
||||||
|
diagnostic.field = issue.field;
|
||||||
|
}
|
||||||
|
vec![diagnostic]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
// Frozen schema-2 contract from c20a207. Private migration reader; do not evolve with schema 3.
|
||||||
|
//! Structural state-machine rules. Expressions describe intent; they are not executed.
|
||||||
|
use super::{
|
||||||
|
model::*,
|
||||||
|
validation::{Issue, ancestor_of_kind, error, warning},
|
||||||
|
};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
fn ancestors(p: &Project, id: Id) -> Vec<Id> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||||
|
let mut seen = BTreeSet::from([id]);
|
||||||
|
while let Some(id) = current {
|
||||||
|
if !seen.insert(id) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result.push(id);
|
||||||
|
current = p.elements.get(&id).and_then(|e| e.parent);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn within(p: &Project, id: Id, owner: Id) -> bool {
|
||||||
|
ancestors(p, id).contains(&owner)
|
||||||
|
}
|
||||||
|
fn boundary(p: &Project, vertex: &Element) -> Option<Id> {
|
||||||
|
vertex
|
||||||
|
.parent
|
||||||
|
.and_then(|region| p.elements.get(®ion))
|
||||||
|
.and_then(|region| region.parent)
|
||||||
|
}
|
||||||
|
fn first_region(p: &Project, vertex: Id, owner: Id) -> Option<Id> {
|
||||||
|
ancestors(p, vertex).into_iter().find(|id| {
|
||||||
|
p.elements
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|e| e.kind() == Kind::Region && e.parent == Some(owner))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn edges<'a>(
|
||||||
|
p: &'a Project,
|
||||||
|
candidate: Option<&'a Relation>,
|
||||||
|
) -> impl Iterator<Item = &'a Relation> {
|
||||||
|
p.relations
|
||||||
|
.values()
|
||||||
|
.filter(move |r| candidate.is_none_or(|c| c.id != r.id))
|
||||||
|
.chain(candidate)
|
||||||
|
.filter(|r| r.kind() == RelationKind::StateTransition)
|
||||||
|
}
|
||||||
|
fn guard(r: &Relation) -> &str {
|
||||||
|
match &r.body {
|
||||||
|
RelationBody::StateTransition { guard, .. } => guard.trim(),
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn orthogonal(p: &Project, arms: &[Id]) -> bool {
|
||||||
|
let Some(first) = arms.first() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if arms.len() < 2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(owner) = ancestors(p, *first).into_iter().find(|id| {
|
||||||
|
p.elements
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|e| e.kind().is_stable_state() || e.kind() == Kind::StateMachine)
|
||||||
|
&& arms.iter().all(|arm| within(p, *arm, *id))
|
||||||
|
}) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let regions: Option<BTreeSet<_>> = arms
|
||||||
|
.iter()
|
||||||
|
.map(|arm| first_region(p, *arm, owner))
|
||||||
|
.collect();
|
||||||
|
regions.is_some_and(|regions| regions.len() == arms.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_transition(
|
||||||
|
p: &Project,
|
||||||
|
r: &Relation,
|
||||||
|
a: &Element,
|
||||||
|
b: &Element,
|
||||||
|
out: &mut Vec<Issue>,
|
||||||
|
) {
|
||||||
|
let RelationBody::StateTransition {
|
||||||
|
transition_kind,
|
||||||
|
trigger,
|
||||||
|
triggers,
|
||||||
|
guard,
|
||||||
|
..
|
||||||
|
} = &r.body
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let machine = ancestor_of_kind(p, a.id, Kind::StateMachine);
|
||||||
|
if machine.is_none() || machine != ancestor_of_kind(p, b.id, Kind::StateMachine) {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
"transition_machine",
|
||||||
|
"endpoints",
|
||||||
|
"State transition endpoints belong to one state machine.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !a.kind().is_state() || !b.kind().is_state() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if a.id == b.id && !a.kind().is_stable_state() {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
"pseudostate_self",
|
||||||
|
"endpoints",
|
||||||
|
"Only a State or Mode can have a self-transition.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let valid_kind = match transition_kind {
|
||||||
|
TransitionKind::Internal => a.kind().is_stable_state() && a.id == b.id,
|
||||||
|
TransitionKind::Local if a.kind() == Kind::EntryPoint => {
|
||||||
|
boundary(p, a).is_some_and(|owner| within(p, b.id, owner))
|
||||||
|
}
|
||||||
|
TransitionKind::Local => {
|
||||||
|
a.kind().is_stable_state()
|
||||||
|
&& p.children(a.id).any(|e| e.kind() == Kind::Region)
|
||||||
|
&& within(p, b.id, a.id)
|
||||||
|
}
|
||||||
|
TransitionKind::External => a.kind() != Kind::EntryPoint,
|
||||||
|
};
|
||||||
|
if !valid_kind {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
"transition_kind",
|
||||||
|
"transition_kind",
|
||||||
|
"Internal requires one stable state; Local goes from a composite or EntryPoint into its boundary; EntryPoint routing must be Local.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if matches!(a.kind(), Kind::Final | Kind::Terminate) || b.kind() == Kind::Initial {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
"pseudostate_direction",
|
||||||
|
"endpoints",
|
||||||
|
"Final/Terminate have no outgoing transitions; Initial has no incoming transition.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let triggered = !trigger.trim().is_empty() || !triggers.is_empty();
|
||||||
|
if !a.kind().is_stable_state() && triggered {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
if a.kind() == Kind::Initial {
|
||||||
|
"initial_transition"
|
||||||
|
} else {
|
||||||
|
"pseudostate_trigger"
|
||||||
|
},
|
||||||
|
"triggers",
|
||||||
|
"Pseudostate routing cannot wait for an event/work trigger; put the trigger on the incoming stable-state transition.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (matches!(
|
||||||
|
a.kind(),
|
||||||
|
Kind::Initial | Kind::Fork | Kind::ShallowHistory | Kind::DeepHistory
|
||||||
|
) || b.kind() == Kind::Join)
|
||||||
|
&& (!guard.trim().is_empty() || (b.kind() == Kind::Join && triggered))
|
||||||
|
{
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
if a.kind() == Kind::Initial {
|
||||||
|
"initial_transition"
|
||||||
|
} else {
|
||||||
|
"pseudostate_segment"
|
||||||
|
},
|
||||||
|
"guard",
|
||||||
|
"Initial/history fallback and Fork output/Join input segments have no guard or trigger.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if a.kind() == Kind::Fork && !b.kind().is_stable_state()
|
||||||
|
|| b.kind() == Kind::Join && !a.kind().is_stable_state()
|
||||||
|
{
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
"orthogonal_endpoint",
|
||||||
|
"endpoints",
|
||||||
|
"Fork outputs and Join inputs connect stable States/Modes in distinct orthogonal regions.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if matches!(a.kind(), Kind::ShallowHistory | Kind::DeepHistory) {
|
||||||
|
let scope = a.parent.is_some_and(|region| {
|
||||||
|
if a.kind() == Kind::ShallowHistory {
|
||||||
|
b.parent == Some(region)
|
||||||
|
} else {
|
||||||
|
within(p, b.id, region)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !b.kind().is_stable_state() || !scope {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
"history_scope",
|
||||||
|
"target",
|
||||||
|
"Shallow history falls back to a direct stable child of its region; deep history to a stable descendant within that region.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (point, other, outgoing) in [(a, b, true), (b, a, false)] {
|
||||||
|
if !matches!(point.kind(), Kind::EntryPoint | Kind::ExitPoint) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(owner) = boundary(p, point) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let needs_inside = (point.kind() == Kind::EntryPoint) == outgoing;
|
||||||
|
if within(p, other.id, owner) != needs_inside || other.id == owner {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
r.id,
|
||||||
|
"boundary_transition",
|
||||||
|
"endpoints",
|
||||||
|
"EntryPoint routes from outside to inside its owner boundary; ExitPoint routes from inside to outside it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_vertex(
|
||||||
|
p: &Project,
|
||||||
|
e: &Element,
|
||||||
|
candidate: Option<&Relation>,
|
||||||
|
out: &mut Vec<Issue>,
|
||||||
|
) {
|
||||||
|
if !e.kind().is_state() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let incoming: Vec<_> = edges(p, candidate).filter(|r| r.target() == e.id).collect();
|
||||||
|
let outgoing: Vec<_> = edges(p, candidate).filter(|r| r.source() == e.id).collect();
|
||||||
|
let (ins, outs) = (incoming.len(), outgoing.len());
|
||||||
|
let max_in = matches!(e.kind(), Kind::Choice | Kind::Fork).then_some(1);
|
||||||
|
let max_out = matches!(
|
||||||
|
e.kind(),
|
||||||
|
Kind::Initial | Kind::Join | Kind::ShallowHistory | Kind::DeepHistory | Kind::ExitPoint
|
||||||
|
)
|
||||||
|
.then_some(1);
|
||||||
|
if max_in.is_some_and(|max| ins > max) || max_out.is_some_and(|max| outs > max) {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
if e.kind() == Kind::Initial {
|
||||||
|
"initial_transition_count"
|
||||||
|
} else {
|
||||||
|
"pseudostate_cardinality"
|
||||||
|
},
|
||||||
|
"body",
|
||||||
|
format!(
|
||||||
|
"{} has too many {} transitions.",
|
||||||
|
e.kind(),
|
||||||
|
if max_in.is_some_and(|max| ins > max) {
|
||||||
|
"incoming"
|
||||||
|
} else {
|
||||||
|
"outgoing"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let incomplete = match e.kind() {
|
||||||
|
Kind::Initial | Kind::ShallowHistory | Kind::DeepHistory => outs == 0,
|
||||||
|
Kind::Final | Kind::Terminate => ins == 0,
|
||||||
|
Kind::Choice | Kind::Fork => ins == 0 || outs < 2,
|
||||||
|
Kind::Join => ins < 2 || outs == 0,
|
||||||
|
Kind::Junction => ins == 0 || outs == 0,
|
||||||
|
Kind::EntryPoint => outs == 0,
|
||||||
|
Kind::ExitPoint => {
|
||||||
|
ins == 0
|
||||||
|
|| (outs == 0
|
||||||
|
&& boundary(p, e)
|
||||||
|
.and_then(|id| p.elements.get(&id))
|
||||||
|
.is_some_and(|e| e.kind() != Kind::StateMachine))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if incomplete {
|
||||||
|
warning(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"incomplete_state_vertex",
|
||||||
|
"body",
|
||||||
|
format!(
|
||||||
|
"Finish the required {} incoming/outgoing routing; incomplete vertices can be saved while wiring.",
|
||||||
|
e.kind()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if e.kind() == Kind::Choice {
|
||||||
|
let defaults = outgoing.iter().filter(|r| guard(r) == "else").count();
|
||||||
|
if defaults > 1 {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"choice_default",
|
||||||
|
"guard",
|
||||||
|
"Choice has at most one literal else branch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if defaults == 0 {
|
||||||
|
warning(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"missing_choice_default",
|
||||||
|
"guard",
|
||||||
|
"Add an else branch, or review and document why no alternative may apply.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if outgoing.iter().any(|r| guard(r).is_empty()) {
|
||||||
|
warning(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"missing_choice_guard",
|
||||||
|
"guard",
|
||||||
|
"Each Choice alternative needs an explicit guard or literal else.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.kind() == Kind::Junction && outs > 1 && outgoing.iter().any(|r| guard(r).is_empty()) {
|
||||||
|
warning(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"junction_guard",
|
||||||
|
"guard",
|
||||||
|
"Review the unguarded Junction alternatives; guard expressions are not interpreted for disjointness.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if matches!(e.kind(), Kind::Fork | Kind::Join) {
|
||||||
|
let arms: Vec<_> = if e.kind() == Kind::Fork {
|
||||||
|
outgoing.iter().map(|r| r.target()).collect()
|
||||||
|
} else {
|
||||||
|
incoming.iter().map(|r| r.source()).collect()
|
||||||
|
};
|
||||||
|
if !orthogonal(p, &arms) {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"orthogonal_regions",
|
||||||
|
"endpoints",
|
||||||
|
"Each Fork/Join arm must occupy a distinct first region below their nearest common composite or machine; deeper nesting does not create concurrency.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.kind() == Kind::EntryPoint
|
||||||
|
&& let Some(owner) = boundary(p, e)
|
||||||
|
{
|
||||||
|
let regions: Vec<_> = outgoing
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| first_region(p, r.target(), owner))
|
||||||
|
.collect();
|
||||||
|
let distinct: BTreeSet<_> = regions.iter().copied().collect();
|
||||||
|
if distinct.len() != regions.len() {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"entry_point_cardinality",
|
||||||
|
"target",
|
||||||
|
"EntryPoint has at most one outgoing transition per destination region.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if p.children(owner)
|
||||||
|
.filter(|r| r.kind() == Kind::Region)
|
||||||
|
.any(|r| {
|
||||||
|
!distinct.contains(&r.id) && !p.children(r.id).any(|v| v.kind() == Kind::Initial)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
warning(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"entry_region_default",
|
||||||
|
"target",
|
||||||
|
"Provide an EntryPoint route or Initial default entry for every concurrent region in this boundary.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_graph(p: &Project, out: &mut Vec<Issue>) {
|
||||||
|
for e in p.elements.values() {
|
||||||
|
validate_vertex(p, e, None, out);
|
||||||
|
if e.kind() == Kind::Region {
|
||||||
|
for (kind, code) in [
|
||||||
|
(Kind::Initial, "multiple_initial"),
|
||||||
|
(Kind::ShallowHistory, "multiple_shallow_history"),
|
||||||
|
(Kind::DeepHistory, "multiple_deep_history"),
|
||||||
|
] {
|
||||||
|
if p.children(e.id).filter(|c| c.kind() == kind).count() > 1 {
|
||||||
|
error(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
code,
|
||||||
|
"parent",
|
||||||
|
format!("A region has at most one {kind}."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let explicit_entry =
|
||||||
|
edges(p, None).any(|r| within(p, r.target(), e.id) && !within(p, r.source(), e.id));
|
||||||
|
if !p
|
||||||
|
.children(e.id)
|
||||||
|
.any(|v| matches!(v.kind(), Kind::Initial | Kind::EntryPoint))
|
||||||
|
&& !explicit_entry
|
||||||
|
{
|
||||||
|
warning(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"missing_initial",
|
||||||
|
"parent",
|
||||||
|
"Add an Initial default entry or an explicit incoming entry path for this region.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.kind() == Kind::StateMachine {
|
||||||
|
validate_reachability(p, e.id, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_reachability(p: &Project, machine: Id, out: &mut Vec<Issue>) {
|
||||||
|
let mut pending: Vec<_> = p
|
||||||
|
.children(machine)
|
||||||
|
.filter(|e| e.kind() == Kind::Region)
|
||||||
|
.flat_map(|region| p.children(region.id))
|
||||||
|
.filter(|e| matches!(e.kind(), Kind::Initial | Kind::EntryPoint))
|
||||||
|
.map(|e| e.id)
|
||||||
|
.collect();
|
||||||
|
let mut reached = BTreeSet::new();
|
||||||
|
while let Some(id) = pending.pop() {
|
||||||
|
if !reached.insert(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.extend(
|
||||||
|
edges(p, None)
|
||||||
|
.filter(|r| r.source() == id)
|
||||||
|
.map(Relation::target),
|
||||||
|
);
|
||||||
|
// Entering a nested vertex activates its composite ancestors. Reaching a
|
||||||
|
// composite also permits each region's default entry; this deliberately
|
||||||
|
// over-approximates guards/history instead of pretending to execute them.
|
||||||
|
pending.extend(ancestors(p, id).into_iter().filter(|id| {
|
||||||
|
p.elements
|
||||||
|
.get(id)
|
||||||
|
.is_some_and(|e| e.kind().is_stable_state())
|
||||||
|
}));
|
||||||
|
pending.extend(
|
||||||
|
p.children(id)
|
||||||
|
.filter(|e| e.kind() == Kind::Region)
|
||||||
|
.flat_map(|region| p.children(region.id))
|
||||||
|
.filter(|e| e.kind() == Kind::Initial)
|
||||||
|
.map(|e| e.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for e in p.elements.values().filter(|e| {
|
||||||
|
e.kind().is_state() && ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine)
|
||||||
|
}) {
|
||||||
|
if !reached.contains(&e.id) {
|
||||||
|
warning(
|
||||||
|
out,
|
||||||
|
e.id,
|
||||||
|
"unreachable_state",
|
||||||
|
"body",
|
||||||
|
"No structural path from a machine entry reaches this vertex; review entry, nesting and transitions (guards are not executed).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use super::model::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Drift {
|
||||||
|
Unreviewed,
|
||||||
|
Unchanged,
|
||||||
|
OaChanged,
|
||||||
|
SaChanged,
|
||||||
|
BothChanged,
|
||||||
|
}
|
||||||
|
pub fn trace_drift(p: &Project, t: &Trace) -> Drift {
|
||||||
|
let Some(base) = &t.baseline else {
|
||||||
|
return Drift::Unreviewed;
|
||||||
|
};
|
||||||
|
let (Some(oa), Some(sa)) = (p.properties(t.oa), p.properties(t.sa)) else {
|
||||||
|
return Drift::Unreviewed;
|
||||||
|
};
|
||||||
|
match (oa != base.oa, sa != base.sa) {
|
||||||
|
(false, false) => Drift::Unchanged,
|
||||||
|
(true, false) => Drift::OaChanged,
|
||||||
|
(false, true) => Drift::SaChanged,
|
||||||
|
(true, true) => Drift::BothChanged,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,48 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{io::Write, path::Path};
|
use std::{io::Write, path::Path};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
#[path = "migration_v2/mod.rs"]
|
||||||
|
pub(crate) mod v2;
|
||||||
|
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MigrationReport {
|
||||||
|
pub from_schema: u32,
|
||||||
|
pub to_schema: u32,
|
||||||
|
pub preserved_ports: Vec<PreservedPort>,
|
||||||
|
pub generated_endpoints: Vec<GeneratedEndpoint>,
|
||||||
|
pub orientation_defaults: Vec<OrientationDefault>,
|
||||||
|
pub required_resolutions: Vec<Id>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PreservedPort {
|
||||||
|
pub id: Id,
|
||||||
|
pub alias: String,
|
||||||
|
pub required_resolution: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct GeneratedEndpoint {
|
||||||
|
pub exchange: Id,
|
||||||
|
pub side: String,
|
||||||
|
pub port: Id,
|
||||||
|
pub owner: Id,
|
||||||
|
pub alias: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct OrientationDefault {
|
||||||
|
pub exchange: Id,
|
||||||
|
pub oriented: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DecodeResult {
|
||||||
|
pub project: Project,
|
||||||
|
pub migration: Option<MigrationReport>,
|
||||||
|
}
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum FileError {
|
pub enum FileError {
|
||||||
#[error("File operation failed: {0}")]
|
#[error("File operation failed: {0}")]
|
||||||
@@ -11,6 +53,11 @@ pub enum FileError {
|
|||||||
Text(String),
|
Text(String),
|
||||||
#[error("Invalid project: {0}")]
|
#[error("Invalid project: {0}")]
|
||||||
Invalid(String),
|
Invalid(String),
|
||||||
|
#[error("Invalid project: {message}")]
|
||||||
|
Model {
|
||||||
|
message: String,
|
||||||
|
issues: Vec<validation::Issue>,
|
||||||
|
},
|
||||||
#[error(
|
#[error(
|
||||||
"This file changed outside Arcadia Studio. Save as a new file, or reopen it to review the external changes."
|
"This file changed outside Arcadia Studio. Save as a new file, or reopen it to review the external changes."
|
||||||
)]
|
)]
|
||||||
@@ -19,21 +66,30 @@ pub enum FileError {
|
|||||||
Limit(String),
|
Limit(String),
|
||||||
}
|
}
|
||||||
pub fn decode(bytes: &[u8]) -> Result<Project, FileError> {
|
pub fn decode(bytes: &[u8]) -> Result<Project, FileError> {
|
||||||
decode_limited(bytes, crate::language::ParseLimits::UNLIMITED)
|
decode_with_report(bytes).map(|decoded| decoded.project)
|
||||||
|
}
|
||||||
|
pub fn decode_with_report(bytes: &[u8]) -> Result<DecodeResult, FileError> {
|
||||||
|
decode_with_report_limited(bytes, crate::language::ParseLimits::UNLIMITED)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply admission budgets before validating a candidate or its migrated form.
|
|
||||||
pub fn decode_limited(
|
pub fn decode_limited(
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
limits: crate::language::ParseLimits,
|
limits: crate::language::ParseLimits,
|
||||||
) -> Result<Project, FileError> {
|
) -> Result<Project, FileError> {
|
||||||
|
decode_with_report_limited(bytes, limits).map(|decoded| decoded.project)
|
||||||
|
}
|
||||||
|
/// Admission budgets cover the original graph and all generated migration data.
|
||||||
|
/// Decoding never rewrites a file or advances a hosted revision.
|
||||||
|
pub fn decode_with_report_limited(
|
||||||
|
bytes: &[u8],
|
||||||
|
limits: crate::language::ParseLimits,
|
||||||
|
) -> Result<DecodeResult, FileError> {
|
||||||
if bytes.len() > limits.max_source_bytes {
|
if bytes.len() > limits.max_source_bytes {
|
||||||
return Err(FileError::Limit(format!(
|
return Err(FileError::Limit(format!(
|
||||||
"Input exceeds {} bytes.",
|
"Input exceeds {} bytes.",
|
||||||
limits.max_source_bytes
|
limits.max_source_bytes
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let p: Project = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||||
match header
|
match header
|
||||||
.get("schema_version")
|
.get("schema_version")
|
||||||
@@ -42,43 +98,63 @@ pub fn decode_limited(
|
|||||||
Some(1) => {
|
Some(1) => {
|
||||||
let old = serde_json::from_slice(bytes)?;
|
let old = serde_json::from_slice(bytes)?;
|
||||||
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||||
migrate_v1_limited(old, limits).map_err(FileError::Invalid)?
|
migrate_v1_with_report_limited(old, limits)?
|
||||||
}
|
}
|
||||||
Some(2) => serde_json::from_slice(bytes)?,
|
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||||
|
Some(3) => DecodeResult {
|
||||||
|
project: serde_json::from_slice(bytes)?,
|
||||||
|
migration: None,
|
||||||
|
},
|
||||||
_ => {
|
_ => {
|
||||||
return Err(FileError::Invalid(
|
return Err(FileError::Invalid(
|
||||||
"Unsupported or missing schema_version; expected 1 or 2.".into(),
|
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?;
|
let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?;
|
||||||
crate::language::parse_limited(text, limits).map_err(|diagnostics| {
|
crate::language::parse_with_report_limited(text, limits).map_err(|diagnostics| {
|
||||||
let detail = diagnostics
|
let detail = diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if diagnostics.iter().any(|diagnostic| {
|
if diagnostics
|
||||||
matches!(diagnostic.code.as_str(), "model_limit" | "source_limit")
|
.iter()
|
||||||
}) {
|
.any(|d| matches!(d.code.as_str(), "model_limit" | "source_limit"))
|
||||||
|
{
|
||||||
FileError::Limit(detail)
|
FileError::Limit(detail)
|
||||||
} else {
|
} else {
|
||||||
FileError::Text(detail)
|
FileError::Text(detail)
|
||||||
}
|
}
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
limits.check_model(&p).map_err(FileError::Limit)?;
|
limits
|
||||||
validate_project(&p)?;
|
.check_model(&decoded.project)
|
||||||
Ok(p)
|
.map_err(FileError::Limit)?;
|
||||||
|
validate_project(&decoded.project)?;
|
||||||
|
Ok(decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn migrate_v1_with_report_limited(
|
||||||
|
old: v1::Project,
|
||||||
|
limits: crate::language::ParseLimits,
|
||||||
|
) -> Result<DecodeResult, FileError> {
|
||||||
|
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||||
|
let normalized = normalize_v1_to_v2(old, limits).map_err(FileError::Invalid)?;
|
||||||
|
let mut decoded = migrate_v2_limited(normalized, limits)?;
|
||||||
|
if let Some(report) = &mut decoded.migration {
|
||||||
|
report.from_schema = 1;
|
||||||
|
}
|
||||||
|
Ok(decoded)
|
||||||
|
}
|
||||||
/// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and
|
/// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and
|
||||||
/// coordinates survive; every newly required identity is deterministic.
|
/// coordinates survive; every newly required identity is deterministic.
|
||||||
pub(crate) fn migrate_v1_limited(
|
fn normalize_v1_to_v2(
|
||||||
mut old: v1::Project,
|
mut old: v1::Project,
|
||||||
limits: crate::language::ParseLimits,
|
limits: crate::language::ParseLimits,
|
||||||
) -> Result<Project, String> {
|
) -> Result<v2::model::Project, String> {
|
||||||
|
use v2::model::*;
|
||||||
check_v1_limits(&old, limits)?;
|
check_v1_limits(&old, limits)?;
|
||||||
if let Some(issue) = v1::validation::validate(&old).into_iter().next() {
|
if let Some(issue) = v1::validation::validate(&old).into_iter().next() {
|
||||||
return Err(issue.message);
|
return Err(issue.message);
|
||||||
@@ -180,7 +256,10 @@ pub(crate) fn migrate_v1_limited(
|
|||||||
id,
|
id,
|
||||||
alias,
|
alias,
|
||||||
element: e.id,
|
element: e.id,
|
||||||
position: e.position,
|
position: Position {
|
||||||
|
x: e.position.x,
|
||||||
|
y: e.position.y,
|
||||||
|
},
|
||||||
size: Size {
|
size: Size {
|
||||||
width: 224.,
|
width: 224.,
|
||||||
height: 94.,
|
height: 94.,
|
||||||
@@ -236,9 +315,9 @@ pub(crate) fn migrate_v1_limited(
|
|||||||
}
|
}
|
||||||
*p.elements.get_mut(&e.id).unwrap().steps_mut().unwrap() = steps;
|
*p.elements.get_mut(&e.id).unwrap().steps_mut().unwrap() = steps;
|
||||||
}
|
}
|
||||||
if let Some(issue) = validation::validate(&p)
|
if let Some(issue) = v2::validation::validate(&p)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|i| i.severity == validation::Severity::Error)
|
.find(|i| i.severity == v2::validation::Severity::Error)
|
||||||
{
|
{
|
||||||
return Err(std::format!(
|
return Err(std::format!(
|
||||||
"V1 migration cannot satisfy V2 validation: {}",
|
"V1 migration cannot satisfy V2 validation: {}",
|
||||||
@@ -263,7 +342,7 @@ pub(crate) fn check_v1_limits(
|
|||||||
let records = graph.saturating_add(old.elements.len()).saturating_add(2);
|
let records = graph.saturating_add(old.elements.len()).saturating_add(2);
|
||||||
limits.check_counts(graph, records, steps)
|
limits.check_counts(graph, records, steps)
|
||||||
}
|
}
|
||||||
fn migration_id(p: &Project, role: &str) -> Id {
|
fn migration_id(p: &v2::model::Project, role: &str) -> Id {
|
||||||
let mut suffix = 0;
|
let mut suffix = 0;
|
||||||
loop {
|
loop {
|
||||||
let id = Id::new_v5(
|
let id = Id::new_v5(
|
||||||
@@ -478,12 +557,18 @@ fn validate_project(p: &Project) -> Result<(), FileError> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|i| i.severity == validation::Severity::Error)
|
.find(|i| i.severity == validation::Severity::Error)
|
||||||
{
|
{
|
||||||
return Err(FileError::Invalid(i.message));
|
return Err(FileError::Model {
|
||||||
|
message: i.message.clone(),
|
||||||
|
issues: vec![i],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn load(path: &Path) -> Result<Project, FileError> {
|
pub fn load(path: &Path) -> Result<Project, FileError> {
|
||||||
decode(&std::fs::read(path)?)
|
load_with_report(path).map(|decoded| decoded.project)
|
||||||
|
}
|
||||||
|
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
|
||||||
|
decode_with_report(&std::fs::read(path)?)
|
||||||
}
|
}
|
||||||
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
|
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
|
||||||
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
|
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
|
||||||
|
|||||||
@@ -252,6 +252,63 @@ project mission "Harbor safety — 港 🌊" {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.push(id);
|
.push(id);
|
||||||
}
|
}
|
||||||
|
let owner = p
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.find(|e| e.alias == "safety_system")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let function = p
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.find(|e| e.alias == "detect")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let component_port = Id::new_v4();
|
||||||
|
let function_port = Id::new_v4();
|
||||||
|
for (id, alias, parent, contract) in [
|
||||||
|
(
|
||||||
|
component_port,
|
||||||
|
"typed_component_port",
|
||||||
|
owner,
|
||||||
|
PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Standard,
|
||||||
|
orientation: ComponentPortOrientation::Unset,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
function_port,
|
||||||
|
"typed_function_output",
|
||||||
|
function,
|
||||||
|
PortContract::FunctionOutput {
|
||||||
|
outgoing_items: vec![],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
p.elements.insert(
|
||||||
|
id,
|
||||||
|
Element {
|
||||||
|
id,
|
||||||
|
alias: alias.into(),
|
||||||
|
name: alias.into(),
|
||||||
|
description: String::new(),
|
||||||
|
parent: Some(parent),
|
||||||
|
body: ElementBody::shared_in(
|
||||||
|
Layer::Sa,
|
||||||
|
SharedBody::Port {
|
||||||
|
contract,
|
||||||
|
legacy: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
p.relate(
|
||||||
|
RelationKind::PortAllocation,
|
||||||
|
"Typed port allocation",
|
||||||
|
component_port,
|
||||||
|
function_port,
|
||||||
|
);
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +461,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||||
for (source, expected) in [
|
for (source, expected) in [
|
||||||
("arcadia 3; project mission \"x\" {}", "Unsupported schema"),
|
("arcadia 4; project mission \"x\" {}", "Unsupported schema"),
|
||||||
(
|
(
|
||||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||||
"Unknown field",
|
"Unknown field",
|
||||||
@@ -708,6 +765,8 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
|||||||
assert_eq!(from_source, from_json);
|
assert_eq!(from_source, from_json);
|
||||||
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
||||||
let mut retained = serde_json::to_value(&from_json).unwrap();
|
let mut retained = serde_json::to_value(&from_json).unwrap();
|
||||||
|
assert_eq!(retained["schema_version"], 3);
|
||||||
|
retained["schema_version"] = 2.into();
|
||||||
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
|
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
|
||||||
for layer in ["Oa", "Sa"] {
|
for layer in ["Oa", "Sa"] {
|
||||||
let Some(shared) = element["body"]
|
let Some(shared) = element["body"]
|
||||||
@@ -716,6 +775,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
|||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if let Some(port) = shared.get_mut("Port") {
|
||||||
|
assert_eq!(port["contract"], "Legacy");
|
||||||
|
*port = port["legacy"].clone();
|
||||||
|
}
|
||||||
if let Some(scenario) = shared
|
if let Some(scenario) = shared
|
||||||
.get_mut("Scenario")
|
.get_mut("Scenario")
|
||||||
.and_then(serde_json::Value::as_object_mut)
|
.and_then(serde_json::Value::as_object_mut)
|
||||||
@@ -735,6 +798,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
|
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
|
||||||
|
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
|
||||||
|
assert_eq!(boundary["oriented"], true);
|
||||||
|
boundary.as_object_mut().unwrap().remove("oriented");
|
||||||
|
}
|
||||||
if let Some(transition) = relation["body"]
|
if let Some(transition) = relation["body"]
|
||||||
.get_mut("StateTransition")
|
.get_mut("StateTransition")
|
||||||
.and_then(serde_json::Value::as_object_mut)
|
.and_then(serde_json::Value::as_object_mut)
|
||||||
@@ -828,7 +895,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
|
|||||||
assert_eq!(from_text, from_json);
|
assert_eq!(from_text, from_json);
|
||||||
assert_eq!(read(text), from_text);
|
assert_eq!(read(text), from_text);
|
||||||
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
||||||
assert_eq!(from_text.schema_version, 2);
|
assert_eq!(from_text.schema_version, 3);
|
||||||
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
|
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||||
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
|
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||||
for (id, record) in raw["elements"].as_object().unwrap() {
|
for (id, record) in raw["elements"].as_object().unwrap() {
|
||||||
@@ -1027,7 +1094,9 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if code.trim_start().starts_with("arcadia 2;") {
|
if code.trim_start().starts_with("arcadia 2;")
|
||||||
|
|| code.trim_start().starts_with("arcadia 3;")
|
||||||
|
{
|
||||||
read(&code);
|
read(&code);
|
||||||
examples += 1;
|
examples += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
use oa_core::{
|
||||||
|
language::{self, ParseLimits},
|
||||||
|
persistence::{self, FileError},
|
||||||
|
*,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
const ORIGINAL: &[u8] = include_bytes!("fixtures/v2-coastal-rescue.json");
|
||||||
|
fn original() -> Value {
|
||||||
|
serde_json::from_slice(ORIGINAL).unwrap()
|
||||||
|
}
|
||||||
|
fn exchange_id(old: &Value) -> String {
|
||||||
|
old["relations"]
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|(_, r)| r["body"].get("FunctionalExchange").is_some())
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
fn without_endpoints() -> Value {
|
||||||
|
let mut old = original();
|
||||||
|
let id = exchange_id(&old);
|
||||||
|
old["relations"][&id]["body"]["FunctionalExchange"]["source_port"] = Value::Null;
|
||||||
|
old["relations"][&id]["body"]["FunctionalExchange"]["target_port"] = Value::Null;
|
||||||
|
old
|
||||||
|
}
|
||||||
|
fn decode(old: &Value) -> persistence::DecodeResult {
|
||||||
|
persistence::decode_with_report(&serde_json::to_vec(old).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||||
|
let old = original();
|
||||||
|
let decoded = persistence::decode_with_report(ORIGINAL).unwrap();
|
||||||
|
let from_source =
|
||||||
|
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
|
||||||
|
assert_eq!(decoded.project, from_source.project);
|
||||||
|
assert_eq!(decoded.migration, from_source.migration);
|
||||||
|
let report = decoded.migration.unwrap();
|
||||||
|
assert_eq!((report.from_schema, report.to_schema), (2, 3));
|
||||||
|
assert_eq!(report.preserved_ports.len(), 4);
|
||||||
|
assert!(report.generated_endpoints.is_empty());
|
||||||
|
assert_eq!(report.orientation_defaults.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
decoded.project.identities().len(),
|
||||||
|
old["elements"].as_object().unwrap().len()
|
||||||
|
+ old["relations"].as_object().unwrap().len()
|
||||||
|
+ old["diagrams"]
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.map(|d| 1 + d["nodes"].as_object().unwrap().len())
|
||||||
|
.sum::<usize>()
|
||||||
|
+ old["traces"].as_object().unwrap().len()
|
||||||
|
+ old["artifact_links"].as_object().unwrap().len()
|
||||||
|
+ 1
|
||||||
|
);
|
||||||
|
let current = serde_json::to_value(&decoded.project).unwrap();
|
||||||
|
for field in [
|
||||||
|
"id",
|
||||||
|
"alias",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"diagrams",
|
||||||
|
"traces",
|
||||||
|
"artifact_links",
|
||||||
|
] {
|
||||||
|
assert_eq!(current[field], old[field], "preserve {field}");
|
||||||
|
}
|
||||||
|
for (id, before) in old["elements"].as_object().unwrap() {
|
||||||
|
let after = ¤t["elements"][id];
|
||||||
|
for field in ["id", "alias", "name", "description", "parent"] {
|
||||||
|
assert_eq!(after[field], before[field], "{id}.{field}");
|
||||||
|
}
|
||||||
|
for layer in ["Oa", "Sa"] {
|
||||||
|
if let Some(port) = before["body"][layer]["Shared"].get("Port") {
|
||||||
|
assert_eq!(
|
||||||
|
after["body"][layer]["Shared"]["Port"],
|
||||||
|
json!({"contract":"Legacy", "legacy":port})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (id, before) in old["relations"].as_object().unwrap() {
|
||||||
|
let after = ¤t["relations"][id];
|
||||||
|
for field in ["id", "alias", "name", "description"] {
|
||||||
|
assert_eq!(after[field], before[field]);
|
||||||
|
}
|
||||||
|
if before["body"].get("StateTransition").is_none() {
|
||||||
|
let mut expected = before.clone();
|
||||||
|
if let Some(body) = expected["body"].get_mut("BoundaryExchange") {
|
||||||
|
body["oriented"] = true.into();
|
||||||
|
}
|
||||||
|
assert_eq!(*after, expected, "relation {id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||||
|
let old = without_endpoints();
|
||||||
|
let first = decode(&old);
|
||||||
|
let second = decode(&old);
|
||||||
|
assert_eq!(first.project, second.project);
|
||||||
|
assert_eq!(first.migration, second.migration);
|
||||||
|
let report = first.migration.as_ref().unwrap();
|
||||||
|
assert_eq!(report.generated_endpoints.len(), 2);
|
||||||
|
let fe: Id = exchange_id(&old).parse().unwrap();
|
||||||
|
let items = first.project.relations[&fe].body.items();
|
||||||
|
for endpoint in &report.generated_endpoints {
|
||||||
|
assert_eq!(
|
||||||
|
endpoint.port,
|
||||||
|
Id::new_v5(
|
||||||
|
&first.project.id,
|
||||||
|
format!("v2-fe-port:{fe}:{}", endpoint.side).as_bytes()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let port = &first.project.elements[&endpoint.port];
|
||||||
|
assert_eq!(port.parent, Some(endpoint.owner));
|
||||||
|
assert_eq!(port.alias, endpoint.alias);
|
||||||
|
match port.shared().unwrap() {
|
||||||
|
SharedBody::Port {
|
||||||
|
contract: PortContract::FunctionOutput { outgoing_items },
|
||||||
|
legacy: None,
|
||||||
|
} => {
|
||||||
|
assert_eq!(endpoint.side, "source");
|
||||||
|
assert_eq!(outgoing_items, items);
|
||||||
|
}
|
||||||
|
SharedBody::Port {
|
||||||
|
contract: PortContract::FunctionInput { incoming_items },
|
||||||
|
legacy: None,
|
||||||
|
} => {
|
||||||
|
assert_eq!(endpoint.side, "target");
|
||||||
|
assert_eq!(incoming_items, items);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected migrated endpoint {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let canonical = language::format(&first.project);
|
||||||
|
assert!(canonical.starts_with("arcadia 3;"));
|
||||||
|
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||||
|
assert!(reopened.migration.is_none());
|
||||||
|
assert_eq!(reopened.project, first.project);
|
||||||
|
assert_eq!(canonical, language::format(&reopened.project));
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = directory.path().join("migrated.json");
|
||||||
|
persistence::save(&path, &first.project).unwrap();
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
let loaded = persistence::load_with_report(&path).unwrap();
|
||||||
|
assert!(loaded.migration.is_none());
|
||||||
|
persistence::save(&path, &loaded.project).unwrap();
|
||||||
|
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_endpoint_collision_in_any_object_category_is_located_and_rejected() {
|
||||||
|
let mut old = without_endpoints();
|
||||||
|
let fe = exchange_id(&old);
|
||||||
|
let project: Id = old["id"].as_str().unwrap().parse().unwrap();
|
||||||
|
let collision = Id::new_v5(&project, format!("v2-fe-port:{fe}:source").as_bytes());
|
||||||
|
old["artifact_links"].as_object_mut().unwrap().insert(collision.to_string(), json!({"id":collision,"alias":"collision_artifact","name":"Keep this identity","description":"Never overwrite me","owner":project,"relation":"References","uri":"docs/evidence.md","revision":null,"fragment":null}));
|
||||||
|
let bytes = serde_json::to_vec(&old).unwrap();
|
||||||
|
let error = persistence::decode(&bytes).unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
error.contains("identity_collision")
|
||||||
|
&& error.contains(&fe)
|
||||||
|
&& error.contains("source_port")
|
||||||
|
&& error.contains(&collision.to_string()),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
assert_eq!(serde_json::to_vec(&old).unwrap(), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_alias_collision_is_deterministically_suffixed() {
|
||||||
|
let mut old = without_endpoints();
|
||||||
|
let first = decode(&old);
|
||||||
|
let generated = &first.migration.unwrap().generated_endpoints[0];
|
||||||
|
let original_port = old["elements"]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.values_mut()
|
||||||
|
.find(|e| e["body"]["Sa"]["Shared"].get("Port").is_some())
|
||||||
|
.unwrap();
|
||||||
|
original_port["alias"] = generated.alias.clone().into();
|
||||||
|
let decoded = decode(&old);
|
||||||
|
let endpoint = &decoded.migration.unwrap().generated_endpoints[0];
|
||||||
|
assert_eq!(endpoint.port, generated.port);
|
||||||
|
assert_ne!(endpoint.alias, generated.alias);
|
||||||
|
assert!(endpoint.alias.starts_with(&generated.alias));
|
||||||
|
assert_eq!(decoded.project, decode(&old).project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_admission_counts_generated_objects_records_and_references() {
|
||||||
|
let old = without_endpoints();
|
||||||
|
let bytes = serde_json::to_vec(&old).unwrap();
|
||||||
|
let graph =
|
||||||
|
old["elements"].as_object().unwrap().len() + old["relations"].as_object().unwrap().len();
|
||||||
|
let migrated = decode(&old).project;
|
||||||
|
let records = migrated.identities().len();
|
||||||
|
let reference_count = migrated
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.map(|e| e.steps().map_or(0, Vec::len))
|
||||||
|
.sum::<usize>()
|
||||||
|
+ decode(&old)
|
||||||
|
.migration
|
||||||
|
.unwrap()
|
||||||
|
.generated_endpoints
|
||||||
|
.iter()
|
||||||
|
.map(|endpoint| {
|
||||||
|
migrated.elements[&endpoint.port]
|
||||||
|
.shared()
|
||||||
|
.map_or(0, |body| match body {
|
||||||
|
SharedBody::Port { contract, .. } => contract.items().len() + 2,
|
||||||
|
_ => 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.sum::<usize>();
|
||||||
|
for limits in [
|
||||||
|
ParseLimits {
|
||||||
|
max_graph_objects: graph + 1,
|
||||||
|
..ParseLimits::UNLIMITED
|
||||||
|
},
|
||||||
|
ParseLimits {
|
||||||
|
max_records: records - 1,
|
||||||
|
..ParseLimits::UNLIMITED
|
||||||
|
},
|
||||||
|
ParseLimits {
|
||||||
|
max_step_references: reference_count - 1,
|
||||||
|
..ParseLimits::UNLIMITED
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
persistence::decode_with_report_limited(&bytes, limits),
|
||||||
|
Err(FileError::Limit(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let accepted = ParseLimits {
|
||||||
|
max_graph_objects: graph + 2,
|
||||||
|
max_records: records,
|
||||||
|
max_step_references: reference_count,
|
||||||
|
..ParseLimits::UNLIMITED
|
||||||
|
};
|
||||||
|
assert!(persistence::decode_with_report_limited(&bytes, accepted).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
|
||||||
|
let mut old = original();
|
||||||
|
let fe = exchange_id(&old);
|
||||||
|
let port = old["relations"][&fe]["body"]["FunctionalExchange"]["source_port"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned();
|
||||||
|
old["elements"][&port]["body"]["Sa"]["Shared"]["Port"]["direction"] = "In".into();
|
||||||
|
let error = persistence::decode(&serde_json::to_vec(&old).unwrap())
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(error.contains("V2") && error.contains(&fe), "{error}");
|
||||||
|
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
|
||||||
|
let result = persistence::decode_with_report(v1).unwrap();
|
||||||
|
assert_eq!(result.project.schema_version, 3);
|
||||||
|
assert_eq!(result.migration.unwrap().from_schema, 1);
|
||||||
|
assert_eq!(persistence::decode(v1).unwrap(), result.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loading_v2_does_not_rewrite_original_bytes() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let path = directory.path().join("original.json");
|
||||||
|
std::fs::write(&path, ORIGINAL).unwrap();
|
||||||
|
let loaded = persistence::load_with_report(&path).unwrap();
|
||||||
|
assert!(loaded.migration.is_some());
|
||||||
|
assert_eq!(std::fs::read(path).unwrap(), ORIGINAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_oa_inout_ports_attachments_and_traces_remain_unclassified() {
|
||||||
|
let source = r##"arcadia 2;
|
||||||
|
project old_ports "Existing engineering" id "10000000-0000-0000-0000-000000000001" {
|
||||||
|
oa_entity observer "Observer" {}
|
||||||
|
oa_actor operator_one "Operator" {}
|
||||||
|
oa_port old_oa "OA channel" { parent @observer; direction InOut; }
|
||||||
|
oa_port peer_oa "OA peer" { parent @operator_one; direction InOut; }
|
||||||
|
boundary_exchange old_oa_link "Operational communication" { source @old_oa; target @peer_oa; }
|
||||||
|
oa_activity observe "Observe" {}
|
||||||
|
oa_activity react "React" {}
|
||||||
|
interaction operational_message "Operational message" { source @observe; target @react; }
|
||||||
|
sa_function send "Send" {}
|
||||||
|
sa_function respond "Respond" {}
|
||||||
|
sa_exchange_item payload "Payload" {}
|
||||||
|
sa_interface old_interface "Original association" { items [@payload]; }
|
||||||
|
sa_port directed "Original directed port" { parent @send; direction Out; interface @old_interface; description "Retain — описание"; }
|
||||||
|
sa_port bidirectional "Original InOut port" id "10000000-0000-0000-0000-000000000002" { parent @respond; direction InOut; interface @old_interface; }
|
||||||
|
functional_exchange request_message "Request" { source @send; target @respond; source_port @directed; target_port @bidirectional; items [@payload]; }
|
||||||
|
functional_exchange reply_message "Reply" id "10000000-0000-0000-0000-000000000003" { source @respond; target @send; source_port @bidirectional; items [@payload]; }
|
||||||
|
interface_provision legacy_role "Preserved role" { source @bidirectional; target @old_interface; }
|
||||||
|
sa_constraint port_constraint "Original requirement" { constrained [@bidirectional]; expression "review exact meaning"; }
|
||||||
|
artifact attachment "Attached rationale" { owner @bidirectional; uri "docs/original.md"; revision "v2"; fragment "#port"; }
|
||||||
|
trace original_trace "Original correspondence" { oa @old_oa; sa @bidirectional; kind Item; }
|
||||||
|
diagram sa_view "Existing placement" { layer Sa; }
|
||||||
|
node original_node "" { diagram @sa_view; element @bidirectional; position { x -0; y 23.5; }; size { width 44; height 22; }; }
|
||||||
|
}
|
||||||
|
"##;
|
||||||
|
// Node declarations intentionally have no display-name string.
|
||||||
|
let source = source.replace("node original_node \"\"", "node original_node");
|
||||||
|
let decoded = language::parse_with_report(&source).unwrap();
|
||||||
|
let p = decoded.project;
|
||||||
|
let report = decoded.migration.unwrap();
|
||||||
|
assert_eq!(report.preserved_ports.len(), 4);
|
||||||
|
assert_eq!(report.generated_endpoints.len(), 1);
|
||||||
|
assert_eq!(report.orientation_defaults.len(), 1);
|
||||||
|
let original: Id = "10000000-0000-0000-0000-000000000002".parse().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
p.elements[&original].shared(),
|
||||||
|
Some(SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::InOut,
|
||||||
|
interface: Some(_)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
p.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.layer() == Layer::Oa && e.kind() == Kind::Port)
|
||||||
|
.all(|e| matches!(
|
||||||
|
e.shared(),
|
||||||
|
Some(SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(p.artifact_links.values().next().unwrap().owner, original);
|
||||||
|
assert_eq!(p.traces.values().next().unwrap().sa, original);
|
||||||
|
let node = p
|
||||||
|
.diagrams
|
||||||
|
.values()
|
||||||
|
.flat_map(|d| d.nodes.values())
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(node.element, original);
|
||||||
|
assert_eq!(node.position.x.to_bits(), (-0.0f32).to_bits());
|
||||||
|
assert_eq!(node.position.y, 23.5);
|
||||||
|
assert!(
|
||||||
|
p.relations
|
||||||
|
.values()
|
||||||
|
.any(|r| r.kind() == RelationKind::InterfaceProvision && r.source() == original)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p.relations
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.kind() == RelationKind::PortAllocation)
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(p, language::parse(&language::format(&p)).unwrap());
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
use oa_core::{language, persistence, *};
|
||||||
|
|
||||||
|
const SOURCE: &str = r#"arcadia 3;
|
||||||
|
project typed_ports "Typed ports" {
|
||||||
|
sa_system producer "Producer" {}
|
||||||
|
sa_actor consumer "Consumer" {}
|
||||||
|
sa_function send "Send" {}
|
||||||
|
sa_function receive "Receive" {}
|
||||||
|
allocation sends "Produces" { source @producer; target @send; }
|
||||||
|
allocation receives "Consumes" { source @consumer; target @receive; }
|
||||||
|
sa_exchange_item data "Data" {}
|
||||||
|
sa_interface service "Service" { items [@data]; }
|
||||||
|
sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; }
|
||||||
|
sa_port input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }
|
||||||
|
sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; }
|
||||||
|
sa_port client "Client port" { parent @consumer; contract { Component { kind Standard; orientation Unset; }; }; }
|
||||||
|
interface_provision provided "Provides service" { source @provider; target @service; }
|
||||||
|
interface_requirement required "Requires service" { source @client; target @service; }
|
||||||
|
functional_exchange message "Message" { source @send; target @receive; source_port @output; target_port @input; items [@data]; }
|
||||||
|
boundary_exchange connection "Connection" { source @provider; target @client; oriented false; items [@data]; }
|
||||||
|
port_allocation output_allocation "Output allocation" { source @provider; target @output; }
|
||||||
|
port_allocation input_allocation "Input allocation" { source @client; target @input; }
|
||||||
|
exchange_allocation carried "Carried message" { source @connection; target @message; }
|
||||||
|
sa_port preserved "Preserved generic port" { parent @send; contract Legacy; legacy { direction InOut; interface @service; }; }
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||||
|
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||||
|
assert!(decoded.migration.is_none());
|
||||||
|
let canonical = language::format(&decoded.project);
|
||||||
|
for field in [
|
||||||
|
"incoming_items [@data]",
|
||||||
|
"outgoing_items [@data]",
|
||||||
|
"interface @service",
|
||||||
|
"source_port @output",
|
||||||
|
"target_port @input",
|
||||||
|
"oriented false",
|
||||||
|
"port_allocation",
|
||||||
|
] {
|
||||||
|
assert!(canonical.contains(field), "{field}\n{canonical}");
|
||||||
|
}
|
||||||
|
assert_eq!(language::parse(&canonical).unwrap(), decoded.project);
|
||||||
|
assert_eq!(
|
||||||
|
persistence::decode(&serde_json::to_vec(&decoded.project).unwrap()).unwrap(),
|
||||||
|
decoded.project
|
||||||
|
);
|
||||||
|
let preserved = decoded
|
||||||
|
.project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.find(|e| e.alias == "preserved")
|
||||||
|
.unwrap();
|
||||||
|
let service = decoded
|
||||||
|
.project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.find(|e| e.alias == "service")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
assert!(references::element_references(preserved).contains(&service));
|
||||||
|
assert!(
|
||||||
|
language::lint(&canonical)
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.object_id == Some(preserved.id)
|
||||||
|
&& d.severity == validation::Severity::Warning)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
|
||||||
|
for (remove, expected) in [
|
||||||
|
("source_port @output; ", "source_port"),
|
||||||
|
("target_port @input; ", "target_port"),
|
||||||
|
(
|
||||||
|
"contract { FunctionOutput { outgoing_items [@data]; }; };",
|
||||||
|
"contract",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let candidate = SOURCE.replace(remove, "");
|
||||||
|
let errors = language::parse(&candidate).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
errors.iter().any(|d| d.message.contains(expected)),
|
||||||
|
"{errors:?}"
|
||||||
|
);
|
||||||
|
assert!(errors.iter().all(|d| d.line > 0 && d.column > 0));
|
||||||
|
}
|
||||||
|
assert!(language::parse(&SOURCE.replace("source_port @output", "source_port none")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() {
|
||||||
|
let mixed = SOURCE.replace(
|
||||||
|
"parent @consumer; contract { Component { kind Standard; orientation Unset;",
|
||||||
|
"parent @consumer; contract { Component { kind Flow; orientation Unset;",
|
||||||
|
);
|
||||||
|
assert!(language::parse(&mixed).is_err());
|
||||||
|
let typo = SOURCE.replace("interface @service;", "interface @missing;");
|
||||||
|
let error = language::parse(&typo).unwrap_err();
|
||||||
|
assert!(error.iter().any(|d| d.message.contains("@missing")));
|
||||||
|
let item_typo = SOURCE.replace("incoming_items [@data]", "incoming_items [@missing]");
|
||||||
|
assert!(
|
||||||
|
language::parse(&item_typo)
|
||||||
|
.unwrap_err()
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.message.contains("@missing"))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ axum = { version = "0.8", default-features = false, features = ["http1", "json",
|
|||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "io-util"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "io-util"] }
|
||||||
tower-http = { version = "0.6", features = ["limit"] }
|
tower-http = { version = "0.6", features = ["limit"] }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json = { workspace = true, features = ["raw_value"] }
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -182,13 +182,13 @@ fn not_modified(stored: &StoredProject, headers: &HeaderMap) -> Option<Response>
|
|||||||
fn summary(stored: &StoredProject) -> Value {
|
fn summary(stored: &StoredProject) -> Value {
|
||||||
json!({"resource_id":stored.resource_id,"revision":stored.revision,"model_id":stored.model.id,
|
json!({"resource_id":stored.resource_id,"revision":stored.revision,"model_id":stored.model.id,
|
||||||
"schema_version":stored.model.schema_version,"name":stored.model.name,
|
"schema_version":stored.model.schema_version,"name":stored.model.name,
|
||||||
"elements":stored.model.elements.len(),"relations":stored.model.relations.len()})
|
"elements":stored.model.elements.len(),"relations":stored.model.relations.len(),"migration":stored.migration})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_source(body: &[u8]) -> ApiResult<Project> {
|
fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
|
||||||
let source =
|
let source =
|
||||||
std::str::from_utf8(body).map_err(|_| ApiError::bad_request("Source must be UTF-8."))?;
|
std::str::from_utf8(body).map_err(|_| ApiError::bad_request("Source must be UTF-8."))?;
|
||||||
oa_core::language::parse_limited(source, MODEL_LIMITS).map_err(|diagnostics| {
|
oa_core::language::parse_with_report_limited(source, MODEL_LIMITS).map_err(|diagnostics| {
|
||||||
let code = if diagnostics.iter().any(|d| d.code == "model_limit") {
|
let code = if diagnostics.iter().any(|d| d.code == "model_limit") {
|
||||||
"model_limit"
|
"model_limit"
|
||||||
} else {
|
} else {
|
||||||
@@ -215,13 +215,31 @@ fn parse_source(body: &[u8]) -> ApiResult<Project> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_json(body: &[u8]) -> ApiResult<Project> {
|
fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
|
||||||
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 migration.
|
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 and schema-2 migration.
|
||||||
let _: Value = json_body(body)?;
|
let _: Value = json_body(body)?;
|
||||||
oa_core::persistence::decode_limited(body, MODEL_LIMITS).map_err(|error| match error {
|
oa_core::persistence::decode_with_report_limited(body, MODEL_LIMITS).map_err(
|
||||||
|
|error| match error {
|
||||||
oa_core::persistence::FileError::Limit(detail) => {
|
oa_core::persistence::FileError::Limit(detail) => {
|
||||||
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail)
|
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "model_limit", detail)
|
||||||
}
|
}
|
||||||
|
oa_core::persistence::FileError::Model { message, issues } => {
|
||||||
|
let mut error =
|
||||||
|
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "invalid_model", message);
|
||||||
|
error.issues = issues
|
||||||
|
.into_iter()
|
||||||
|
.map(|issue| IssueDto {
|
||||||
|
severity: "error",
|
||||||
|
code: issue.code,
|
||||||
|
message: issue.message,
|
||||||
|
field: issue.field,
|
||||||
|
object_id: issue.element,
|
||||||
|
line: None,
|
||||||
|
column: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
error
|
||||||
|
}
|
||||||
oa_core::persistence::FileError::Json(error) => {
|
oa_core::persistence::FileError::Json(error) => {
|
||||||
ApiError::bad_request(format!("Invalid model JSON: {error}"))
|
ApiError::bad_request(format!("Invalid model JSON: {error}"))
|
||||||
}
|
}
|
||||||
@@ -230,7 +248,8 @@ fn import_json(body: &[u8]) -> ApiResult<Project> {
|
|||||||
"invalid_model",
|
"invalid_model",
|
||||||
error.to_string(),
|
error.to_string(),
|
||||||
),
|
),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -282,7 +301,7 @@ async fn import_project(
|
|||||||
} else {
|
} else {
|
||||||
parse_source(&body)?
|
parse_source(&body)?
|
||||||
};
|
};
|
||||||
created(store.create(model)?)
|
created(store.create_decoded(model)?)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -477,15 +496,15 @@ async fn replace_model(
|
|||||||
blocking(state, move |store| {
|
blocking(state, move |store| {
|
||||||
store.checked(id, &expected)?;
|
store.checked(id, &expected)?;
|
||||||
let model = if is_json {
|
let model = if is_json {
|
||||||
json_body(&body)?
|
import_json(&body)?
|
||||||
} else {
|
} else {
|
||||||
parse_source(&body)?
|
parse_source(&body)?
|
||||||
};
|
};
|
||||||
let (stored, changed) = store.replace(id, &expected, model)?;
|
let (stored, changed) = store.replace_decoded(id, &expected, model)?;
|
||||||
Ok(response(
|
Ok(response(
|
||||||
&stored,
|
&stored,
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
|
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration,"migration":stored.migration}),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -822,10 +841,11 @@ async fn get_validation(State(state): State<AppState>, id: ProjectPath) -> ApiRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(tag = "format", rename_all = "snake_case", deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
enum ValidationRequest {
|
struct ValidationRequest {
|
||||||
Json { model: Project },
|
format: String,
|
||||||
Arcadia { source: String },
|
model: Option<Box<serde_json::value::RawValue>>,
|
||||||
|
source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_candidate(
|
async fn validate_candidate(
|
||||||
@@ -841,15 +861,16 @@ async fn validate_candidate(
|
|||||||
let snapshot = store.get(id)?;
|
let snapshot = store.get(id)?;
|
||||||
let stored = &snapshot;
|
let stored = &snapshot;
|
||||||
let request: ValidationRequest = json_body(&body)?;
|
let request: ValidationRequest = json_body(&body)?;
|
||||||
let parsed = match request {
|
let parsed = match (request.format.as_str(), request.model, request.source) {
|
||||||
ValidationRequest::Json { model } => Ok(model),
|
("json", Some(model), None) => import_json(model.get().as_bytes()),
|
||||||
ValidationRequest::Arcadia { source } => parse_source(source.as_bytes()),
|
("arcadia", None, Some(source)) => parse_source(source.as_bytes()),
|
||||||
|
_ => return Err(ApiError::bad_request("Validation requires either format json with only model, or format arcadia with only source.")),
|
||||||
};
|
};
|
||||||
let (valid, issues) = match parsed.and_then(|model| {
|
let (valid, issues, migration) = match parsed.and_then(|decoded| {
|
||||||
validate_model(&model)?;
|
validate_model(&decoded.project)?;
|
||||||
Ok(model)
|
Ok(decoded)
|
||||||
}) {
|
}) {
|
||||||
Ok(model) => (true, issues(&model)),
|
Ok(decoded) => (true, issues(&decoded.project), decoded.migration),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let mut diagnostics = error.issues;
|
let mut diagnostics = error.issues;
|
||||||
if diagnostics.is_empty() {
|
if diagnostics.is_empty() {
|
||||||
@@ -863,13 +884,13 @@ async fn validate_candidate(
|
|||||||
column: None,
|
column: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
(false, diagnostics)
|
(false, diagnostics, None)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(response(
|
Ok(response(
|
||||||
stored,
|
stored,
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
json!({"revision":stored.revision,"valid":valid,"issues":issues}),
|
json!({"revision":stored.revision,"valid":valid,"issues":issues,"migration":migration}),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Single-owner on-disk envelopes. All methods run on a blocking worker.
|
//! Single-owner on-disk envelopes. All methods run on a blocking worker.
|
||||||
use crate::error::{ApiError, ApiResult, issues};
|
use crate::error::{ApiError, ApiResult, issues};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use oa_core::persistence::{DecodeResult, MigrationReport};
|
||||||
use oa_core::{Id, Project};
|
use oa_core::{Id, Project};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
@@ -29,6 +30,8 @@ pub struct StoredProject {
|
|||||||
pub resource_id: Id,
|
pub resource_id: Id,
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
pub model: Project,
|
pub model: Project,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub migration: Option<MigrationReport>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StoredProject {
|
impl StoredProject {
|
||||||
@@ -46,6 +49,7 @@ pub(crate) struct ProjectSummary {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub elements: usize,
|
pub elements: usize,
|
||||||
pub relations: usize,
|
pub relations: usize,
|
||||||
|
pub migration: Option<MigrationReport>,
|
||||||
}
|
}
|
||||||
impl From<&StoredProject> for ProjectSummary {
|
impl From<&StoredProject> for ProjectSummary {
|
||||||
fn from(stored: &StoredProject) -> Self {
|
fn from(stored: &StoredProject) -> Self {
|
||||||
@@ -57,6 +61,7 @@ impl From<&StoredProject> for ProjectSummary {
|
|||||||
name: stored.model.name.clone(),
|
name: stored.model.name.clone(),
|
||||||
elements: stored.model.elements.len(),
|
elements: stored.model.elements.len(),
|
||||||
relations: stored.model.relations.len(),
|
relations: stored.model.relations.len(),
|
||||||
|
migration: stored.migration.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,11 +93,11 @@ struct StoreInner {
|
|||||||
|
|
||||||
/// Constructed only after complete candidate validation, outside the catalog
|
/// Constructed only after complete candidate validation, outside the catalog
|
||||||
/// mutex. Commit methods cannot accidentally accept an unchecked candidate.
|
/// mutex. Commit methods cannot accidentally accept an unchecked candidate.
|
||||||
struct PreparedModel(Project);
|
struct PreparedModel(Project, Option<MigrationReport>);
|
||||||
impl PreparedModel {
|
impl PreparedModel {
|
||||||
fn new(model: Project) -> ApiResult<Self> {
|
fn new(model: Project) -> ApiResult<Self> {
|
||||||
validate_model(&model)?;
|
validate_model(&model)?;
|
||||||
Ok(Self(model))
|
Ok(Self(model, None))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +134,22 @@ impl Store {
|
|||||||
self.lock()?.create(prepared)
|
self.lock()?.create(prepared)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn create_decoded(&self, decoded: DecodeResult) -> ApiResult<StoredProject> {
|
||||||
|
let mut prepared = PreparedModel::new(decoded.project)?;
|
||||||
|
prepared.1 = decoded.migration;
|
||||||
|
self.lock()?.create(prepared)
|
||||||
|
}
|
||||||
|
pub(crate) fn replace_decoded(
|
||||||
|
&self,
|
||||||
|
id: Id,
|
||||||
|
expected: &str,
|
||||||
|
decoded: DecodeResult,
|
||||||
|
) -> ApiResult<(StoredProject, bool)> {
|
||||||
|
let mut prepared = PreparedModel::new(decoded.project)?;
|
||||||
|
prepared.1 = decoded.migration;
|
||||||
|
self.lock()?.replace(id, expected, prepared)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn replace(
|
pub(crate) fn replace(
|
||||||
&self,
|
&self,
|
||||||
id: Id,
|
id: Id,
|
||||||
@@ -201,8 +222,26 @@ impl Store {
|
|||||||
"At most 128 models and 256 MiB of serialized envelopes are supported.".into(),
|
"At most 128 models and 256 MiB of serialized envelopes are supported.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let stored: StoredProject =
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Envelope {
|
||||||
|
resource_id: Id,
|
||||||
|
revision: u64,
|
||||||
|
model: Box<serde_json::value::RawValue>,
|
||||||
|
}
|
||||||
|
let envelope: Envelope =
|
||||||
serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?;
|
serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?;
|
||||||
|
let decoded = oa_core::persistence::decode_with_report_limited(
|
||||||
|
envelope.model.get().as_bytes(),
|
||||||
|
MODEL_LIMITS,
|
||||||
|
)
|
||||||
|
.map_err(|error| corrupt(error.to_string()))?;
|
||||||
|
let stored = StoredProject {
|
||||||
|
resource_id: envelope.resource_id,
|
||||||
|
revision: envelope.revision,
|
||||||
|
model: decoded.project,
|
||||||
|
migration: decoded.migration,
|
||||||
|
};
|
||||||
if stored.resource_id != filename_id || stored.revision == 0 {
|
if stored.resource_id != filename_id || stored.revision == 0 {
|
||||||
return Err(corrupt(
|
return Err(corrupt(
|
||||||
"Resource ID/filename mismatch or zero revision.".into(),
|
"Resource ID/filename mismatch or zero revision.".into(),
|
||||||
@@ -254,6 +293,7 @@ impl StoreInner {
|
|||||||
resource_id: Id::new_v4(),
|
resource_id: Id::new_v4(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
model,
|
model,
|
||||||
|
migration: prepared.1,
|
||||||
};
|
};
|
||||||
let size = self.write(&stored, true)?;
|
let size = self.write(&stored, true)?;
|
||||||
self.adopt(stored.clone(), size);
|
self.adopt(stored.clone(), size);
|
||||||
@@ -270,7 +310,11 @@ impl StoreInner {
|
|||||||
let current = self.checked(id, expected)?;
|
let current = self.checked(id, expected)?;
|
||||||
let model = prepared.0;
|
let model = prepared.0;
|
||||||
if current.model == model {
|
if current.model == model {
|
||||||
return Ok((current.clone(), false));
|
let mut unchanged = current.clone();
|
||||||
|
if prepared.1.is_some() {
|
||||||
|
unchanged.migration = prepared.1;
|
||||||
|
}
|
||||||
|
return Ok((unchanged, false));
|
||||||
}
|
}
|
||||||
let revision = current.revision.checked_add(1).ok_or_else(|| {
|
let revision = current.revision.checked_add(1).ok_or_else(|| {
|
||||||
ApiError::new(
|
ApiError::new(
|
||||||
@@ -283,6 +327,7 @@ impl StoreInner {
|
|||||||
resource_id: id,
|
resource_id: id,
|
||||||
revision,
|
revision,
|
||||||
model,
|
model,
|
||||||
|
migration: prepared.1,
|
||||||
};
|
};
|
||||||
let size = self.write(&stored, false)?;
|
let size = self.write(&stored, false)?;
|
||||||
self.adopt(stored.clone(), size);
|
self.adopt(stored.clone(), size);
|
||||||
@@ -312,7 +357,9 @@ impl StoreInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write(&self, stored: &StoredProject, create: bool) -> ApiResult<usize> {
|
fn write(&self, stored: &StoredProject, create: bool) -> ApiResult<usize> {
|
||||||
let bytes = serde_json::to_vec(stored)
|
// Migration observations are response metadata, never persisted facts.
|
||||||
|
let envelope = serde_json::json!({"resource_id": stored.resource_id, "revision": stored.revision, "model": stored.model});
|
||||||
|
let bytes = serde_json::to_vec(&envelope)
|
||||||
.map_err(|_| ApiError::internal("Could not serialize model."))?;
|
.map_err(|_| ApiError::internal("Could not serialize model."))?;
|
||||||
if bytes.len() > MAX_ENVELOPE_BYTES {
|
if bytes.len() > MAX_ENVELOPE_BYTES {
|
||||||
return Err(ApiError::new(
|
return Err(ApiError::new(
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ use serde_json::{Value, json};
|
|||||||
use std::{path::Path, sync::Arc};
|
use std::{path::Path, sync::Arc};
|
||||||
use tokio::{sync::oneshot, task::JoinHandle};
|
use tokio::{sync::oneshot, task::JoinHandle};
|
||||||
|
|
||||||
|
fn v2_missing_ports() -> Value {
|
||||||
|
let mut old: Value = serde_json::from_slice(include_bytes!(
|
||||||
|
"../../oa-core/tests/fixtures/v2-coastal-rescue.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
for relation in old["relations"].as_object_mut().unwrap().values_mut() {
|
||||||
|
if let Some(exchange) = relation["body"].get_mut("FunctionalExchange") {
|
||||||
|
exchange["source_port"] = Value::Null;
|
||||||
|
exchange["target_port"] = Value::Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
old
|
||||||
|
}
|
||||||
|
|
||||||
struct TestServer {
|
struct TestServer {
|
||||||
base: String,
|
base: String,
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -1318,6 +1332,7 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() {
|
|||||||
std::fs::write(&path, "{broken").unwrap();
|
std::fs::write(&path, "{broken").unwrap();
|
||||||
assert!(Store::open(directory.path()).is_err());
|
assert!(Store::open(directory.path()).is_err());
|
||||||
let mismatched = StoredProject {
|
let mismatched = StoredProject {
|
||||||
|
migration: None,
|
||||||
resource_id: Id::new_v4(),
|
resource_id: Id::new_v4(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
model: Project::empty(),
|
model: Project::empty(),
|
||||||
@@ -1325,6 +1340,7 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() {
|
|||||||
std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap();
|
std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap();
|
||||||
assert!(Store::open(directory.path()).is_err());
|
assert!(Store::open(directory.path()).is_err());
|
||||||
let invalid = StoredProject {
|
let invalid = StoredProject {
|
||||||
|
migration: None,
|
||||||
resource_id: filename,
|
resource_id: filename,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
model: Project::empty(),
|
model: Project::empty(),
|
||||||
@@ -1484,6 +1500,7 @@ async fn fixed_store_capacity_is_enforced_on_http_and_restart() {
|
|||||||
let mut last = None;
|
let mut last = None;
|
||||||
for _ in 0..oa_server::store::MAX_PROJECTS {
|
for _ in 0..oa_server::store::MAX_PROJECTS {
|
||||||
let envelope = StoredProject {
|
let envelope = StoredProject {
|
||||||
|
migration: None,
|
||||||
resource_id: Id::new_v4(),
|
resource_id: Id::new_v4(),
|
||||||
revision: 1,
|
revision: 1,
|
||||||
model: Project::empty(),
|
model: Project::empty(),
|
||||||
@@ -1624,3 +1641,154 @@ async fn legacy_json_import_and_model_reidentification_keep_the_service_address(
|
|||||||
);
|
);
|
||||||
server.stop().await;
|
server.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schema3_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let server = TestServer::start(directory.path(), None).await;
|
||||||
|
let old = v2_missing_ports();
|
||||||
|
let expected =
|
||||||
|
oa_core::persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||||
|
let imported = server
|
||||||
|
.client
|
||||||
|
.post(server.url("/projects/import"))
|
||||||
|
.json(&old)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(imported.status(), StatusCode::CREATED);
|
||||||
|
let etag = imported.headers()["etag"].to_str().unwrap().to_owned();
|
||||||
|
let summary: Value = imported.json().await.unwrap();
|
||||||
|
assert_eq!(summary["schema_version"], 3);
|
||||||
|
assert_eq!(
|
||||||
|
summary["migration"],
|
||||||
|
serde_json::to_value(&expected.migration).unwrap()
|
||||||
|
);
|
||||||
|
let id: Id = summary["resource_id"].as_str().unwrap().parse().unwrap();
|
||||||
|
let stored = server.get(id).await;
|
||||||
|
assert_eq!(stored.model, expected.project);
|
||||||
|
assert_eq!(stored.migration, expected.migration);
|
||||||
|
let filename = directory.path().join(format!("{id}.json"));
|
||||||
|
let before = std::fs::read(&filename).unwrap();
|
||||||
|
let envelope: Value = serde_json::from_slice(&before).unwrap();
|
||||||
|
assert!(envelope.get("migration").is_none());
|
||||||
|
assert_eq!(envelope["model"]["schema_version"], 3);
|
||||||
|
let validation = server
|
||||||
|
.client
|
||||||
|
.post(server.url(&format!("/projects/{id}/validation")))
|
||||||
|
.json(&json!({"format":"json", "model":old}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(validation.status(), StatusCode::OK);
|
||||||
|
let validation: Value = validation.json().await.unwrap();
|
||||||
|
assert_eq!(validation["valid"], true);
|
||||||
|
assert_eq!(validation["migration"], summary["migration"]);
|
||||||
|
let mut invalid = stored
|
||||||
|
.model
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.find(|r| r.kind() == RelationKind::FunctionalExchange)
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
if let oa_core::RelationBody::FunctionalExchange { source_port, .. } = &mut invalid.body {
|
||||||
|
*source_port = Id::nil();
|
||||||
|
}
|
||||||
|
let rejected = server
|
||||||
|
.client
|
||||||
|
.post(server.url(&format!("/projects/{id}/transactions")))
|
||||||
|
.header("if-match", &etag)
|
||||||
|
.json(&json!({"commands":[Command::EditRelation(invalid.id, invalid)]}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
assert_eq!(server.get(id).await.revision, 1);
|
||||||
|
assert_eq!(std::fs::read(&filename).unwrap(), before);
|
||||||
|
let source = server
|
||||||
|
.client
|
||||||
|
.get(server.url(&format!("/projects/{id}/source")))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(source.starts_with("arcadia 3;"));
|
||||||
|
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
|
||||||
|
let no_op = server
|
||||||
|
.client
|
||||||
|
.put(server.url(&format!("/projects/{id}/source")))
|
||||||
|
.header("content-type", "text/plain")
|
||||||
|
.header("if-match", &etag)
|
||||||
|
.body(source)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(no_op.status(), StatusCode::OK);
|
||||||
|
assert_eq!(no_op.headers()["etag"].to_str().unwrap(), etag);
|
||||||
|
assert_eq!(std::fs::read(&filename).unwrap(), before);
|
||||||
|
server.stop().await;
|
||||||
|
let restarted = TestServer::start(directory.path(), None).await;
|
||||||
|
let reopened = restarted.get(id).await;
|
||||||
|
assert_eq!(reopened.model, stored.model);
|
||||||
|
assert_eq!(reopened.revision, 1);
|
||||||
|
assert!(reopened.migration.is_none());
|
||||||
|
let no_op_legacy = restarted
|
||||||
|
.client
|
||||||
|
.put(restarted.url(&format!("/projects/{id}/model")))
|
||||||
|
.header("if-match", &etag)
|
||||||
|
.json(&old)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(no_op_legacy.status(), StatusCode::OK);
|
||||||
|
assert_eq!(no_op_legacy.headers()["etag"].to_str().unwrap(), etag);
|
||||||
|
let no_op_legacy: Value = no_op_legacy.json().await.unwrap();
|
||||||
|
assert_eq!(no_op_legacy["changed"], false);
|
||||||
|
assert_eq!(no_op_legacy["migration"], summary["migration"]);
|
||||||
|
assert_eq!(std::fs::read(&filename).unwrap(), before);
|
||||||
|
restarted.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_revision() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let id = Id::new_v4();
|
||||||
|
let filename = directory.path().join(format!("{id}.json"));
|
||||||
|
let old = v2_missing_ports();
|
||||||
|
let original = serde_json::to_vec(&json!({"resource_id":id,"revision":7,"model":old})).unwrap();
|
||||||
|
std::fs::write(&filename, &original).unwrap();
|
||||||
|
let server = TestServer::start(directory.path(), None).await;
|
||||||
|
let migrated = server.get(id).await;
|
||||||
|
assert_eq!(migrated.resource_id, id);
|
||||||
|
assert_eq!(migrated.revision, 7);
|
||||||
|
assert_eq!(migrated.model.schema_version, 3);
|
||||||
|
assert_eq!(
|
||||||
|
migrated
|
||||||
|
.migration
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.generated_endpoints
|
||||||
|
.len(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&filename).unwrap(), original);
|
||||||
|
let rename = server
|
||||||
|
.client
|
||||||
|
.patch(server.url(&format!("/projects/{id}")))
|
||||||
|
.header("if-match", migrated.etag())
|
||||||
|
.json(&json!({"name":"Reviewed migrated model"}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rename.status(), StatusCode::OK);
|
||||||
|
let current = server.get(id).await;
|
||||||
|
assert_eq!(current.revision, 8);
|
||||||
|
assert_eq!(current.model.name, "Reviewed migrated model");
|
||||||
|
let saved: Value = serde_json::from_slice(&std::fs::read(&filename).unwrap()).unwrap();
|
||||||
|
assert_eq!(saved["revision"], 8);
|
||||||
|
assert_eq!(saved["model"]["schema_version"], 3);
|
||||||
|
assert!(saved.get("migration").is_none());
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
|||||||
+121
-32
@@ -1,7 +1,7 @@
|
|||||||
Arcadia Studio OA / SA Language and Editing Contract
|
Arcadia Studio OA / SA Language and Editing Contract
|
||||||
====================================================
|
====================================================
|
||||||
|
|
||||||
:Version: 2
|
:Version: 3
|
||||||
:Status: Normative current implementation contract; first-release integration
|
:Status: Normative current implementation contract; first-release integration
|
||||||
:Core implementation: ``crates/oa-core``
|
:Core implementation: ``crates/oa-core``
|
||||||
:Text suffix: ``.arcadia``
|
:Text suffix: ``.arcadia``
|
||||||
@@ -11,17 +11,15 @@ Arcadia Studio OA / SA Language and Editing Contract
|
|||||||
|
|
||||||
This specification defines the shared Operational Analysis (OA) and System
|
This specification defines the shared Operational Analysis (OA) and System
|
||||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||||
validation, persistence, and explicit migration from version 1. MUST, MUST NOT,
|
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
|
||||||
and MAY indicate requirements, prohibitions, and permitted choices.
|
and MAY indicate requirements, prohibitions, and permitted choices.
|
||||||
|
|
||||||
The first-release capability/mission, contextual process/chain, typed scenario
|
Schema 3 introduces typed function and component ports, required functional
|
||||||
and state/event additions
|
exchange endpoints, independently oriented boundary exchanges, and explicit
|
||||||
are temporarily integrated under schema 2. This does not declare the first
|
PortAllocation relations. First-release scenario/state additions remain in
|
||||||
release complete or waive its remaining scenario, state, data, transition and
|
scope; this schema change does not declare native acceptance or the wider
|
||||||
usability requirements. A distinct release schema and explicit V2 migration
|
release complete. The preserved schema-2 reader validates the original
|
||||||
will be specified after those interfaces are frozen. This document describes
|
contract before migration.
|
||||||
the current implemented records, rather than inventing compatibility rules for
|
|
||||||
that future schema.
|
|
||||||
|
|
||||||
OA describes stakeholders, operational work and needs. SA describes the
|
OA describes stakeholders, operational work and needs. SA describes the
|
||||||
selected system boundary, external actors, functions, services and expected
|
selected system boundary, external actors, functions, services and expected
|
||||||
@@ -42,7 +40,7 @@ Actual verification evidence is separate from this contract.
|
|||||||
2. One authoritative model
|
2. One authoritative model
|
||||||
--------------------------
|
--------------------------
|
||||||
|
|
||||||
A Project stores ``schema_version`` (2), ``id``, ``alias``, ``name``,
|
A Project stores ``schema_version`` (3), ``id``, ``alias``, ``name``,
|
||||||
``description``, and maps named ``elements``, ``relations``, ``diagrams``,
|
``description``, and maps named ``elements``, ``relations``, ``diagrams``,
|
||||||
``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in
|
``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in
|
||||||
its record. Duplicate map keys and mismatching keys/identities are errors.
|
its record. Duplicate map keys and mismatching keys/identities are errors.
|
||||||
@@ -108,8 +106,8 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
|
|||||||
- ``items``: exchange-item refs
|
- ``items``: exchange-item refs
|
||||||
- ``[]``
|
- ``[]``
|
||||||
* - ``oa_port``, ``sa_port``
|
* - ``oa_port``, ``sa_port``
|
||||||
- ``direction``: In, Out, InOut; ``interface``: ref?
|
- ``contract``: required PortContract; ``legacy``: optional LegacyPortContract
|
||||||
- InOut; none
|
- No implicit contract; ``legacy`` defaults none. OA admits Legacy only.
|
||||||
* - ``oa_scenario``, ``sa_scenario``
|
* - ``oa_scenario``, ``sa_scenario``
|
||||||
- ``viewpoint``: ScenarioViewpoint; ``context``: ref?; ``precondition``, ``postcondition``: strings; ``steps``: occurrence refs
|
- ``viewpoint``: ScenarioViewpoint; ``context``: ref?; ``precondition``, ``postcondition``: strings; ``steps``: occurrence refs
|
||||||
- MixedLegacy for omitted schema-2 source; none; ``""``; ``""``; ``[]``
|
- MixedLegacy for omitted schema-2 source; none; ``""``; ``""``; ``[]``
|
||||||
@@ -205,7 +203,7 @@ contain activities; functions contain functions. Capability grouping uses
|
|||||||
same-layer capability parents; it does not mean inheritance or inclusion.
|
same-layer capability parents; it does not mean inheritance or inclusion.
|
||||||
Operational and system missions are root semantic records; they do not own
|
Operational and system missions are root semantic records; they do not own
|
||||||
capabilities by containment. MissionCapability expresses that association.
|
capabilities by containment. MissionCapability expresses that association.
|
||||||
DataFields belong to ExchangeItems or Record DataTypes. Ports belong to
|
DataFields belong to ExchangeItems or Record DataTypes. Preserved generic ports belong to
|
||||||
participants or work. Lifelines belong to scenarios. State-machine ownership
|
participants or work. Lifelines belong to scenarios. State-machine ownership
|
||||||
uses participant → machine → region → state/mode; a state or mode can contain
|
uses participant → machine → region → state/mode; a state or mode can contain
|
||||||
a nested region. All other state vertices belong to Regions as well. Events
|
a nested region. All other state vertices belong to Regions as well. Events
|
||||||
@@ -235,10 +233,13 @@ references. Their kind is the declaration keyword:
|
|||||||
- ``items`` refs / []
|
- ``items`` refs / []
|
||||||
* - ``functional_exchange``
|
* - ``functional_exchange``
|
||||||
- SA function → SA function
|
- SA function → SA function
|
||||||
- ``source_port``, ``target_port`` / none; ``items`` / []
|
- Required ``source_port``, ``target_port`` refs; ``items`` / []
|
||||||
* - ``boundary_exchange``
|
* - ``boundary_exchange``
|
||||||
- Participant-owned output-compatible port → input-compatible port
|
- Different participant owners; typed component ports have matching kinds
|
||||||
- ``items`` refs / []
|
- ``oriented`` bool / true; ``items`` refs / []
|
||||||
|
* - ``port_allocation``
|
||||||
|
- SA Component port → SA FunctionInput/FunctionOutput port
|
||||||
|
- Requires the function's actual work Allocation to that component
|
||||||
* - ``capability_involvement``
|
* - ``capability_involvement``
|
||||||
- Participant, activity/function, or OA role → capability
|
- Participant, activity/function, or OA role → capability
|
||||||
- None
|
- None
|
||||||
@@ -297,12 +298,75 @@ imply a unique performer.
|
|||||||
Allocation of a non-leaf work element is invalid. A computed graphical summary
|
Allocation of a non-leaf work element is invalid. A computed graphical summary
|
||||||
of its allocated leaves MUST NOT create an extra semantic allocation.
|
of its allocated leaves MUST NOT create an extra semantic allocation.
|
||||||
|
|
||||||
Functional ports, when selected, belong to the corresponding endpoint function
|
Typed function ports belong directly to SA Functions. ``FunctionOutput``
|
||||||
and have compatible direction. Boundary ports belong to participants and have
|
declares ``outgoing_items``; ``FunctionInput`` declares ``incoming_items``.
|
||||||
compatible direction. An interface groups ExchangeItems; an exchange using
|
The lists contain distinct same-layer ExchangeItem references. A functional
|
||||||
that port interface must carry items supported by it. Studio's OA boundary
|
exchange MUST reference output/input ports owned by its respective functions,
|
||||||
exchange is a simplified representation of operational communication; it is
|
and each carried item MUST fit both declarations. Empty declarations accept
|
||||||
not a claim of complete Capella CommunicationMean equivalence.
|
no items. Item compatibility is identity equality; future specialization is
|
||||||
|
not inferred from names. Port declarations remain independent of incident
|
||||||
|
exchanges after creation.
|
||||||
|
|
||||||
|
A ``Component`` contract belongs to an SA System or SystemActor and stores
|
||||||
|
``kind`` (Standard or Flow) and ``orientation`` (Unset, In, Out, InOut).
|
||||||
|
Standard requires Unset. Flow Unset is an incomplete draft, not InOut. Typed
|
||||||
|
component connections require matching kinds: Standard/Flow mismatch is an
|
||||||
|
error. Standard connections normally use ``oriented false``; directional Flow
|
||||||
|
connections use true. Boundary orientation is separate from endpoint kind and
|
||||||
|
orientation. Oriented Flow endpoints permit Out at source and In at target;
|
||||||
|
unoriented Flow endpoints must permit at least one communication direction.
|
||||||
|
|
||||||
|
Provided/required interfaces are separate identified relations. A Standard
|
||||||
|
partner satisfies a requirement by providing the identical interface; stored
|
||||||
|
source/target order does not infer service roles. Component carried items must
|
||||||
|
fit the declared interface-item union when explicit declarations exist. An
|
||||||
|
explicitly empty interface is not a wildcard. Missing declarations and
|
||||||
|
unsatisfied required interfaces produce located completeness warnings.
|
||||||
|
|
||||||
|
``PortAllocation`` relates a Component port to a typed function port only when
|
||||||
|
that function has an actual Allocation to the component owner. Standard may
|
||||||
|
allocate either function direction; Flow directions must agree. A typed
|
||||||
|
ExchangeAllocation requires both corresponding PortAllocations and compatible
|
||||||
|
carried items. Unoriented boundary exchanges allow reversed correspondence.
|
||||||
|
Visual allocation nesting is never evidence of a stored allocation relation.
|
||||||
|
|
||||||
|
``Legacy`` requires ``legacy { direction ...; interface ...; }``. It preserves
|
||||||
|
generic direction/interface data and emits a located compatibility warning.
|
||||||
|
It is an explicit compatibility constructor in JSON/source; ordinary creation
|
||||||
|
pickers do not offer it. Resolution to a typed SA contract retains this record
|
||||||
|
without treating it as another active constraint. OA generic ports remain
|
||||||
|
Legacy; OA Interactions stay activity-to-activity without function ports.
|
||||||
|
Deleting a referenced legacy interface clears that optional live reference.
|
||||||
|
|
||||||
|
A source-3 functional exchange MUST specify both port aliases; the parser does
|
||||||
|
not create endpoints. Graphical/command FE creation plans ports atomically,
|
||||||
|
using unique compatible typed-port reuse or deterministic new endpoints.
|
||||||
|
Port deletion cascades to incident FEs; FE deletion retains independent ports.
|
||||||
|
|
||||||
|
For example::
|
||||||
|
|
||||||
|
arcadia 3;
|
||||||
|
project ports_example "Typed function contract" {
|
||||||
|
sa_function send "Send" {}
|
||||||
|
sa_function receive "Receive" {}
|
||||||
|
sa_exchange_item payload "Payload" {}
|
||||||
|
sa_interface old_service "Preserved service" { items [@payload]; }
|
||||||
|
sa_port output "Output" {
|
||||||
|
parent @send;
|
||||||
|
contract { FunctionOutput { outgoing_items [@payload]; }; };
|
||||||
|
}
|
||||||
|
sa_port input "Input" {
|
||||||
|
parent @receive;
|
||||||
|
contract { FunctionInput { incoming_items [@payload]; }; };
|
||||||
|
}
|
||||||
|
functional_exchange message "Message" {
|
||||||
|
source @send; target @receive;
|
||||||
|
source_port @output; target_port @input; items [@payload];
|
||||||
|
}
|
||||||
|
sa_port preserved "Unresolved original" {
|
||||||
|
parent @send; contract Legacy;
|
||||||
|
legacy { direction InOut; interface @old_service; };
|
||||||
|
}
|
||||||
|
|
||||||
An exclusive region has at most one initial state. Missing initial state is
|
An exclusive region has at most one initial state. Missing initial state is
|
||||||
a completeness warning. Initial states have no incoming transitions and at
|
a completeness warning. Initial states have no incoming transitions and at
|
||||||
@@ -814,16 +878,16 @@ exchanges it references. Cross-layer counterparts are not owned descendants.
|
|||||||
Text edits describe a complete candidate and must remove or repair dependencies
|
Text edits describe a complete candidate and must remove or repair dependencies
|
||||||
explicitly before Apply succeeds.
|
explicitly before Apply succeeds.
|
||||||
|
|
||||||
9. Persistence and V1 migration
|
9. Persistence and explicit migration
|
||||||
-------------------------------
|
-------------------------------------
|
||||||
|
|
||||||
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
|
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
|
||||||
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
|
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
|
||||||
canonical V2 text; other suffixes use V2 JSON. Both forms preserve the same
|
canonical V3 text (``arcadia 3``); other suffixes use V3 JSON. Both forms preserve the same
|
||||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||||
duplicate maps, unsupported versions, and structural errors.
|
duplicate maps, unsupported versions, and structural errors.
|
||||||
|
|
||||||
During first-release schema-2 integration, old Scenario bodies without a
|
The frozen schema-2 reader retains staged compatibility defaults. Old Scenario bodies without a
|
||||||
``viewpoint`` retain MixedLegacy, and old Exchange occurrence bodies without
|
``viewpoint`` retain MixedLegacy, and old Exchange occurrence bodies without
|
||||||
``message_kind``, ``reply_to`` or ``values`` retain Asynchronous, none and [].
|
``message_kind``, ``reply_to`` or ``values`` retain Asynchronous, none and [].
|
||||||
Old StateTransition bodies without ``transition_kind``, ``triggers`` or
|
Old StateTransition bodies without ``transition_kind``, ``triggers`` or
|
||||||
@@ -836,8 +900,33 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
|||||||
artifact or trace field. The original V2 golden source/JSON in
|
artifact or trace field. The original V2 golden source/JSON in
|
||||||
``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo;
|
``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo;
|
||||||
they verify that every old persistent field survives. Canonical output writes
|
they verify that every old persistent field survives. Canonical output writes
|
||||||
the added defaults. A future schema-3 adapter remains a separate required
|
the added defaults through the explicit schema-3 migration.
|
||||||
migration and is not implied by this temporary compatibility behavior.
|
|
||||||
|
The V2 adapter preserves every original identity, alias, prose, parent,
|
||||||
|
placement, trace and artifact. Generic ports become Legacy with their original
|
||||||
|
direction/interface record. Existing FE endpoint IDs remain unchanged. For
|
||||||
|
only a missing endpoint it creates an item-bearing FunctionOutput/Input owned
|
||||||
|
by the already specified function, using UUID v5 in the project namespace
|
||||||
|
with ``v2-fe-port:<fe-id>:source`` or ``v2-fe-port:<fe-id>:target``. Every
|
||||||
|
identity category is checked for collision; collisions are located errors,
|
||||||
|
never overwrites. New aliases follow the deterministic collision-free alias
|
||||||
|
convention. Every old boundary exchange receives ``oriented true``; no
|
||||||
|
PortAllocation is inferred.
|
||||||
|
|
||||||
|
``decode_with_report``, ``load_with_report`` and their limited/source variants
|
||||||
|
return the model plus a migration report containing original unresolved port
|
||||||
|
IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||||
|
orientation defaults. REST import, replacement, candidate validation and
|
||||||
|
project snapshots surface the same report. Opening an old server envelope
|
||||||
|
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||||
|
schema 3. Model/source exports contain only the canonical model. Migration
|
||||||
|
reports are transient response metadata, not persisted model facts.
|
||||||
|
|
||||||
|
Admission checks account for generated objects and their references before
|
||||||
|
construction. Both the old V2 contract and the resulting V3 graph are
|
||||||
|
validated. Migration itself does not save or increase a stored revision.
|
||||||
|
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||||
|
model and then uses this same V2-to-3 stage.
|
||||||
|
|
||||||
Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||||
atomically replaces the destination. Failed serialization or validation must
|
atomically replaces the destination. Failed serialization or validation must
|
||||||
@@ -856,7 +945,7 @@ step becomes a distinct occurrence element owned by its process. Generated
|
|||||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||||
as namespace and deterministic role/index names. If such an identity collides,
|
as namespace and deterministic role/index names. If such an identity collides,
|
||||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||||
input therefore yields the same V2 result, including repeated occurrences.
|
input therefore yields the same V3 result, including repeated occurrences.
|
||||||
No System or SA content is invented during an OA-only migration.
|
No System or SA content is invented during an OA-only migration.
|
||||||
|
|
||||||
V1 source aliases are retained when valid in V2. JSON imports generate the old
|
V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||||
@@ -864,7 +953,7 @@ canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
|||||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||||
A project alias and aliases for new records are generated without colliding
|
A project alias and aliases for new records are generated without colliding
|
||||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||||
V2 projects. Future versions remain errors until a separate migration exists.
|
V3 projects. Future versions remain errors until a separate migration exists.
|
||||||
|
|
||||||
10. Complete authoring example
|
10. Complete authoring example
|
||||||
------------------------------
|
------------------------------
|
||||||
@@ -874,7 +963,7 @@ the assigned UUIDs explicitly.
|
|||||||
|
|
||||||
.. code-block:: text
|
.. code-block:: text
|
||||||
|
|
||||||
arcadia 2;
|
arcadia 3;
|
||||||
project harbor "Harbor assistance" {
|
project harbor "Harbor assistance" {
|
||||||
oa_entity control "Harbor control" {}
|
oa_entity control "Harbor control" {}
|
||||||
oa_activity assess "Assess assistance" {}
|
oa_activity assess "Assess assistance" {}
|
||||||
|
|||||||
+326
-31
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Arcadia Studio model API",
|
"title": "Arcadia Studio model API",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Current schema-2 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag."
|
"description": "Current schema-3 snapshots and conditional atomic writes. Request body limit: 8 MiB; at most 200 nested commands; no retained revisions, request receipts, automatic retries or persistent transition plans. See docs/api.rst. Fixed store capacity: 128 models / 256 MiB serialized envelopes. Stateless transition preview/apply. Artifact URI/revision strings remain opaque core fields. Model admission before structural validation: 5,000 graph objects, 20,000 total records and 50,000 ordered references. Preparation uses snapshots outside the catalog lock; final commits recheck the exact ETag."
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@@ -389,7 +389,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Schema-2 native JSON is described below. Schema-1 JSON is also accepted via the core's explicit migration adapter; see docs/language.rst for the legacy contract. Export emits schema 2."
|
"description": "Schema-3 native JSON is described below. Schema-1 and schema-2 JSON/source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Reports list preserved Legacy contracts, generated FE endpoint IDs/aliases and boundary orientation defaults. Export emits schema 3 / arcadia 3. See docs/language.rst."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/projects/{id}": {
|
"/api/v1/projects/{id}": {
|
||||||
@@ -1711,7 +1711,8 @@
|
|||||||
"CapabilityExtend",
|
"CapabilityExtend",
|
||||||
"CapabilityGeneralization",
|
"CapabilityGeneralization",
|
||||||
"MissionInvolvement",
|
"MissionInvolvement",
|
||||||
"SequenceLink"
|
"SequenceLink",
|
||||||
|
"PortAllocation"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Position": {
|
"Position": {
|
||||||
@@ -2269,19 +2270,13 @@
|
|||||||
"Port": {
|
"Port": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"direction": {
|
"contract": {
|
||||||
"type": "string",
|
"$ref": "#/components/schemas/PortContract"
|
||||||
"enum": [
|
|
||||||
"In",
|
|
||||||
"Out",
|
|
||||||
"InOut"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"interface": {
|
"legacy": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"$ref": "#/components/schemas/LegacyPortContract"
|
||||||
"format": "uuid"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "null"
|
"type": "null"
|
||||||
@@ -2290,8 +2285,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"direction",
|
"contract",
|
||||||
"interface"
|
"legacy"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
@@ -3005,12 +3000,17 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"oriented": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Independent of component port kind/orientation. Old V2 exchanges migrate to true."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"source",
|
"source",
|
||||||
"target",
|
"target",
|
||||||
"items"
|
"items",
|
||||||
|
"oriented"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
@@ -3035,27 +3035,13 @@
|
|||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
"source_port": {
|
"source_port": {
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"target_port": {
|
"target_port": {
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"items": {
|
"items": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -3322,6 +3308,33 @@
|
|||||||
"SequenceLink"
|
"SequenceLink"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"PortAllocation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"source",
|
||||||
|
"target"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"PortAllocation"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -3616,7 +3629,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"schema_version": {
|
"schema_version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"const": 2
|
"const": 3
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -3707,6 +3720,17 @@
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"$ref": "#/components/schemas/Project"
|
"$ref": "#/components/schemas/Project"
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MigrationReport"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Present after legacy import/reopen; excluded from persisted envelopes. Resource identity and revision are unchanged by reopening."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -3745,6 +3769,16 @@
|
|||||||
"relations": {
|
"relations": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 0
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MigrationReport"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4618,6 +4652,16 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/Issue"
|
"$ref": "#/components/schemas/Issue"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MigrationReport"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -4678,6 +4722,16 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/Issue"
|
"$ref": "#/components/schemas/Issue"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MigrationReport"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -5438,6 +5492,247 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"PortContract": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"FunctionInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"incoming_items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"incoming_items"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"FunctionInput"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"FunctionOutput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"outgoing_items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"outgoing_items"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"FunctionOutput"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"Component": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"Standard",
|
||||||
|
"Flow"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"Unset",
|
||||||
|
"In",
|
||||||
|
"Out",
|
||||||
|
"InOut"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"Component"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"Legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "FunctionInput/Output are SA Function-owned item contracts. Component is SA System/SystemActor-owned; Standard requires Unset. Legacy requires a preserved legacy record and emits compatibility warnings. Mixed Standard/Flow component connections are invalid."
|
||||||
|
},
|
||||||
|
"LegacyPortContract": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"direction": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"In",
|
||||||
|
"Out",
|
||||||
|
"InOut"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"interface": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"direction",
|
||||||
|
"interface"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"MigrationReport": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"from_schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"to_schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 3
|
||||||
|
},
|
||||||
|
"preserved_ports": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"alias": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required_resolution": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"alias",
|
||||||
|
"required_resolution"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"generated_endpoints": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"exchange": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"side": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"source",
|
||||||
|
"target"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"alias": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"exchange",
|
||||||
|
"side",
|
||||||
|
"port",
|
||||||
|
"owner",
|
||||||
|
"alias"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"orientation_defaults": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"exchange": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"oriented": {
|
||||||
|
"type": "boolean",
|
||||||
|
"const": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"exchange",
|
||||||
|
"oriented"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required_resolutions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"from_schema",
|
||||||
|
"to_schema",
|
||||||
|
"preserved_ports",
|
||||||
|
"generated_endpoints",
|
||||||
|
"orientation_defaults",
|
||||||
|
"required_resolutions"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Transient observations from explicit V1→V2→V3 or V2→V3 migration. No save or revision increase occurs merely by reading an old envelope."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Typed port migration, source and API handoff
|
||||||
|
|
||||||
|
Implemented in `codex/typed-port-contracts`, isolated worktree
|
||||||
|
`D:/CodexWorktrees/Capella2-typed-ports`, against the frozen port contract.
|
||||||
|
|
||||||
|
- Private V2 DTO, source reader and validation snapshot captured from `c20a207`.
|
||||||
|
V1 normalizes to that DTO, then follows the same V2-to-3 adapter.
|
||||||
|
- Existing port identity/prose/ownership/placement and interface associations
|
||||||
|
survive as Legacy. Missing FE endpoints receive deterministic item-bearing
|
||||||
|
typed ports; old boundary exchanges explicitly retain `oriented = true`.
|
||||||
|
- Generated identities are collision checked across all object categories;
|
||||||
|
aliases use the existing deterministic convention. Original admission limits
|
||||||
|
remain intact, with generated records and references charged before creation.
|
||||||
|
- `persistence::{decode_with_report, decode_with_report_limited, load_with_report}`
|
||||||
|
and `language::{parse_with_report, parse_with_report_limited}` return
|
||||||
|
`DecodeResult { project, migration }`. Existing Project-only wrappers remain.
|
||||||
|
Desktop integration should use the report-returning calls to display the
|
||||||
|
migration findings. Merely loading never writes or advances a revision.
|
||||||
|
- Source 3 requires a port contract and both explicit FE port aliases. Item
|
||||||
|
declarations and nested `legacy.interface` use normal source references.
|
||||||
|
Canonical output is schema 3 / `arcadia 3`; source 1/2 remain importable.
|
||||||
|
- REST import, replacement, candidate validation and project snapshots surface
|
||||||
|
reports. Reopened V2 envelopes retain resource/revision/bytes until a real edit.
|
||||||
|
Reports are transient response metadata, excluded from persisted envelopes.
|
||||||
|
Raw model JSON is preserved through request/envelope parsing so duplicate-map
|
||||||
|
detection cannot be bypassed. Invalid typed transactions retain disk and ETag.
|
||||||
|
- Language/OpenAPI documents include typed contracts, PortAllocation, explicit
|
||||||
|
endpoint IDs, independent orientation, Legacy and strict Standard/Flow kind
|
||||||
|
mismatch rejection. Original V2 goldens and worked-example files are untouched.
|
||||||
|
|
||||||
|
Validation: 16 language, 8 migration, 3 typed source and 4 existing admission
|
||||||
|
tests pass. The HTTP suite has 19 passing tests, including migration/restart and
|
||||||
|
rejected typed transaction coverage. Core+server Clippy with all targets and
|
||||||
|
`-D warnings` passes. Server unit/doc checks are included in final verification.
|
||||||
|
|
||||||
|
Native GUI acceptance and report presentation remain root integration work;
|
||||||
|
this handoff makes no native-UI or broad R3 completion claim.
|
||||||
Reference in New Issue
Block a user