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

This commit is contained in:
2026-09-05 18:23:33 +03:00
parent 0d14438b7b
commit b9f88ce1f3
21 changed files with 7513 additions and 152 deletions
+107 -22
View File
@@ -1,6 +1,48 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::{io::Write, path::Path};
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)]
pub enum FileError {
#[error("File operation failed: {0}")]
@@ -11,6 +53,11 @@ pub enum FileError {
Text(String),
#[error("Invalid project: {0}")]
Invalid(String),
#[error("Invalid project: {message}")]
Model {
message: String,
issues: Vec<validation::Issue>,
},
#[error(
"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),
}
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(
bytes: &[u8],
limits: crate::language::ParseLimits,
) -> 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 {
return Err(FileError::Limit(format!(
"Input exceeds {} 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)?;
match header
.get("schema_version")
@@ -42,43 +98,63 @@ pub fn decode_limited(
Some(1) => {
let old = serde_json::from_slice(bytes)?;
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(
"Unsupported or missing schema_version; expected 1 or 2.".into(),
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
));
}
}
} else {
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
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
if diagnostics.iter().any(|diagnostic| {
matches!(diagnostic.code.as_str(), "model_limit" | "source_limit")
}) {
if diagnostics
.iter()
.any(|d| matches!(d.code.as_str(), "model_limit" | "source_limit"))
{
FileError::Limit(detail)
} else {
FileError::Text(detail)
}
})?
};
limits.check_model(&p).map_err(FileError::Limit)?;
validate_project(&p)?;
Ok(p)
limits
.check_model(&decoded.project)
.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
/// coordinates survive; every newly required identity is deterministic.
pub(crate) fn migrate_v1_limited(
fn normalize_v1_to_v2(
mut old: v1::Project,
limits: crate::language::ParseLimits,
) -> Result<Project, String> {
) -> Result<v2::model::Project, String> {
use v2::model::*;
check_v1_limits(&old, limits)?;
if let Some(issue) = v1::validation::validate(&old).into_iter().next() {
return Err(issue.message);
@@ -180,7 +256,10 @@ pub(crate) fn migrate_v1_limited(
id,
alias,
element: e.id,
position: e.position,
position: Position {
x: e.position.x,
y: e.position.y,
},
size: Size {
width: 224.,
height: 94.,
@@ -236,9 +315,9 @@ pub(crate) fn migrate_v1_limited(
}
*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()
.find(|i| i.severity == validation::Severity::Error)
.find(|i| i.severity == v2::validation::Severity::Error)
{
return Err(std::format!(
"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);
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;
loop {
let id = Id::new_v5(
@@ -478,12 +557,18 @@ fn validate_project(p: &Project) -> Result<(), FileError> {
.into_iter()
.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(())
}
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.
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {