feat: preserve nested source projects across desktop and REST

This commit is contained in:
2026-09-06 00:17:20 +03:00
parent d364d3c058
commit 4e8575bd11
32 changed files with 4283 additions and 417 deletions
+76
View File
@@ -33,6 +33,79 @@ pub struct IssueDto {
pub line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<SpanDto>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub related: Vec<SourceLocationDto>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SpanDto {
pub start: usize,
pub end: usize,
}
impl From<oa_core::language::Span> for SpanDto {
fn from(span: oa_core::language::Span) -> Self {
Self {
start: span.start,
end: span.end,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SourceLocationDto {
pub path: String,
pub span: SpanDto,
}
pub(crate) fn source_issue(issue: oa_core::source_project::SourceDiagnostic) -> IssueDto {
let diagnostic = issue.diagnostic;
IssueDto {
severity: match diagnostic.severity {
oa_core::validation::Severity::Error => "error",
oa_core::validation::Severity::Warning => "warning",
},
code: diagnostic.code,
message: diagnostic.message,
field: diagnostic.field,
object_id: diagnostic.object_id,
line: Some(diagnostic.line),
column: Some(diagnostic.column),
path: Some(issue.path),
span: Some(diagnostic.span.into()),
related: issue
.related
.into_iter()
.map(|location| SourceLocationDto {
path: location.path,
span: location.span.into(),
})
.collect(),
}
}
pub(crate) fn source_error(
diagnostics: Vec<oa_core::source_project::SourceDiagnostic>,
) -> ApiError {
let code = if diagnostics
.iter()
.any(|d| d.diagnostic.code == "model_limit")
{
"model_limit"
} else {
"invalid_source_project"
};
let mut error = ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
code,
"Source project could not be applied.",
);
error.issues = diagnostics.into_iter().map(source_issue).collect();
error
}
impl ApiError {
@@ -106,6 +179,9 @@ pub fn issues(model: &oa_core::Project) -> Vec<IssueDto> {
object_id: issue.element,
line: None,
column: None,
path: None,
span: None,
related: vec![],
})
.collect()
}
+131 -34
View File
@@ -1,7 +1,10 @@
use crate::{
AppState, blocking,
error::{ApiError, ApiResult, IssueDto, issues},
store::{MODEL_LIMITS, StoredProject, check_model_size, validate_model},
error::{ApiError, ApiResult, IssueDto, issues, source_issue},
store::{
MODEL_LIMITS, PreparedProject, SOURCE_LIMITS, StoredProject, check_model_size,
check_source_size, validate_model,
},
};
use axum::{
Json, Router,
@@ -14,6 +17,7 @@ use axum::{
response::{IntoResponse, Response},
routing::{get, post},
};
use oa_core::source_project::{self, SourceBundle};
use oa_core::{Command, Id, Kind, Layer, Project, RelationKind};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
@@ -31,6 +35,10 @@ pub(crate) fn router() -> Router<AppState> {
.route("/api/v1/openapi.json", get(openapi))
.route("/api/v1/projects", get(list_projects).post(create_project))
.route("/api/v1/projects/import", post(import_project))
.route(
"/api/v1/projects/import-source-project",
post(import_source_project),
)
.route(
"/api/v1/projects/{id}",
get(get_project)
@@ -42,6 +50,10 @@ pub(crate) fn router() -> Router<AppState> {
"/api/v1/projects/{id}/source",
get(get_source).put(put_source),
)
.route(
"/api/v1/projects/{id}/source-project",
get(get_source_project).put(put_source_project),
)
.route("/api/v1/projects/{id}/elements", get(list_elements))
.route("/api/v1/projects/{id}/relations", get(list_relations))
.route("/api/v1/projects/{id}/artifact-links", get(list_artifacts))
@@ -209,6 +221,9 @@ fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
object_id: issue.object_id,
line: Some(issue.line),
column: Some(issue.column),
path: None,
span: Some(issue.span.into()),
related: vec![],
})
.collect();
error
@@ -236,6 +251,9 @@ fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
object_id: issue.element,
line: None,
column: None,
path: None,
span: None,
related: vec![],
})
.collect();
error
@@ -306,6 +324,20 @@ async fn import_project(
.await
}
async fn import_source_project(
State(state): State<AppState>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let bundle: SourceBundle = json_body(&body)?;
created(store.create_source_project(bundle)?)
})
.await
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Page {
@@ -377,14 +409,14 @@ async fn rename_project(
let request: NameRequest = json_body(&body)?;
let snapshot = store.checked(id, &expected)?;
let current = &snapshot;
let model = oa_core::execute_batch(
let prepared = oa_core::prepare_command(
&current.model,
&[Command::EditProject {
&Command::EditProject {
id: current.model.id,
alias: current.model.alias.clone(),
name: request.name,
description: current.model.description.clone(),
}],
},
)
.map_err(|error| {
ApiError::new(
@@ -393,7 +425,8 @@ async fn rename_project(
error.to_string(),
)
})?;
let (stored, changed) = store.replace(id, &expected, model)?;
let prepared = PreparedProject::from_command(current, prepared)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
@@ -457,6 +490,48 @@ async fn get_source(
.await
}
async fn get_source_project(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
) -> ApiResult<Response> {
let id = path(id)?;
blocking(state, move |store| {
let stored = store.get(id)?;
Ok(not_modified(&stored, &headers)
.unwrap_or_else(|| response(&stored, StatusCode::OK, &stored.source_project)))
})
.await
}
async fn put_source_project(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
store.checked(id, &expected)?;
let bundle: SourceBundle = json_body(&body)?;
let prepared = PreparedProject::from_bundle(bundle)?;
let diagnostics = prepared.source_issues();
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
json!({
"revision": stored.revision, "changed": changed, "issues": diagnostics,
"source_project": stored.source_project
}),
))
})
.await
}
async fn put_model(
State(state): State<AppState>,
id: ProjectPath,
@@ -504,7 +579,7 @@ async fn replace_model(
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration,"migration":stored.migration}),
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration}),
))
})
.await
@@ -844,8 +919,11 @@ async fn get_validation(State(state): State<AppState>, id: ProjectPath) -> ApiRe
#[serde(deny_unknown_fields)]
struct ValidationRequest {
format: String,
// Keep RawValue directly in the struct: internally tagged enums buffer
// their content and cannot preserve raw model JSON for duplicate-key checks.
model: Option<Box<serde_json::value::RawValue>>,
source: Option<String>,
source_project: Option<SourceBundle>,
}
async fn validate_candidate(
@@ -861,31 +939,31 @@ async fn validate_candidate(
let snapshot = store.get(id)?;
let stored = &snapshot;
let request: ValidationRequest = json_body(&body)?;
let parsed = match (request.format.as_str(), request.model, request.source) {
("json", Some(model), None) => import_json(model.get().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 parsed = match (request.format.as_str(), request.model, request.source, request.source_project) {
("json", Some(model), None, None) => import_json(model.get().as_bytes()),
("arcadia", None, Some(source), None) => parse_source(source.as_bytes()),
("source-project", None, None, Some(source_project)) => {
let result = check_source_size(&source_project).and_then(|()| {
// Advisory locations refer to submitted bytes, before Apply
// would insert missing persistent identities.
let diagnostics: Vec<_> = source_project::lint_bundle(&source_project, SOURCE_LIMITS).into_iter().map(source_issue).collect();
if !diagnostics.iter().any(|issue| issue.severity == "error") {
PreparedProject::from_bundle(source_project)?;
}
Ok(diagnostics)
});
let diagnostics = result.unwrap_or_else(candidate_issues);
let valid = !diagnostics.iter().any(|issue| issue.severity == "error");
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":null})));
}
_ => return Err(ApiError::bad_request("Validation requires one matching format/payload: json with model, arcadia with source, or source-project with source_project.")),
};
let (valid, issues, migration) = match parsed.and_then(|decoded| {
validate_model(&decoded.project)?;
Ok(decoded)
}) {
Ok(decoded) => (true, issues(&decoded.project), decoded.migration),
Err(error) => {
let mut diagnostics = error.issues;
if diagnostics.is_empty() {
diagnostics.push(IssueDto {
severity: "error",
code: error.code.into(),
message: error.detail,
field: None,
object_id: None,
line: None,
column: None,
});
}
(false, diagnostics, None)
}
Err(error) => (false, candidate_issues(error), None),
};
Ok(response(
stored,
@@ -896,6 +974,24 @@ async fn validate_candidate(
.await
}
fn candidate_issues(error: ApiError) -> Vec<IssueDto> {
if !error.issues.is_empty() {
return error.issues;
}
vec![IssueDto {
severity: "error",
code: error.code.into(),
message: error.detail,
field: None,
object_id: None,
line: None,
column: None,
path: None,
span: None,
related: vec![],
}]
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TransactionRequest {
@@ -919,13 +1015,13 @@ async fn transactions(
check_commands(&request.commands)?;
let snapshot = store.checked(id, &expected)?;
let current = &snapshot;
let model = oa_core::execute_batch(&current.model, &request.commands)
let candidate = oa_core::prepare_command(&current.model, &Command::Batch(request.commands))
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "command_rejected", error.to_string()))?;
validate_model(&model)?;
let prepared = PreparedProject::from_command(current, candidate)?;
if request.dry_run {
return Ok(response(current, StatusCode::OK, json!({"revision":current.revision,"dry_run":true,"changed":model != current.model,"issues":issues(&model),"model":model})));
return Ok(response(current, StatusCode::OK, json!({"revision":current.revision,"dry_run":true,"changed":prepared.changed(current),"issues":prepared.source_issues(),"model":prepared.model(),"source_project":prepared.bundle()})));
}
let (stored, changed) = store.replace(id, &expected, model)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(&stored, StatusCode::OK, json!({"revision":stored.revision,"dry_run":false,"changed":changed,"issues":issues(&stored.model)})))
}).await
}
@@ -976,9 +1072,9 @@ async fn preview_transition(
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?;
if !plan.commands.is_empty() { check_commands(&plan.commands)?; }
if plan.conflicts.is_empty() {
let candidate = oa_core::transitions::apply_transition(&current.model, &plan)
let candidate = oa_core::transitions::prepare_transition(&current.model, &plan)
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?;
validate_model(&candidate)?;
PreparedProject::from_command(current, candidate)?;
}
Ok(response(current, StatusCode::OK, json!({"base_revision":current.revision,"plan":plan})))
}).await
@@ -1017,7 +1113,7 @@ async fn commit_transition(
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate = oa_core::transitions::apply_transition(&current.model, &request.plan)
let candidate = oa_core::transitions::prepare_transition(&current.model, &request.plan)
.map_err(|error| {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -1025,7 +1121,8 @@ async fn commit_transition(
error.to_string(),
)
})?;
let (stored, changed) = store.replace(id, &expected, candidate)?;
let prepared = PreparedProject::from_command(current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
+232 -40
View File
@@ -1,7 +1,8 @@
//! Single-owner on-disk envelopes. All methods run on a blocking worker.
use crate::error::{ApiError, ApiResult, issues};
use crate::error::{ApiError, ApiResult, issues, source_error};
use axum::http::StatusCode;
use oa_core::persistence::{DecodeResult, MigrationReport};
use oa_core::source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle};
use oa_core::{Id, Project};
use serde::{Deserialize, Serialize};
use std::{
@@ -13,6 +14,7 @@ use std::{
};
pub const MAX_MODEL_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_SOURCE_PROJECT_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_OBJECTS: usize = 5_000;
pub const MAX_PROJECTS: usize = 128;
pub const MAX_STORE_BYTES: usize = 256 * 1024 * 1024;
@@ -22,7 +24,13 @@ pub(crate) const MODEL_LIMITS: oa_core::language::ParseLimits = oa_core::languag
max_records: 20_000,
max_step_references: 50_000,
};
const MAX_ENVELOPE_BYTES: usize = MAX_MODEL_BYTES + 512;
pub(crate) const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1_024,
model: MODEL_LIMITS,
};
const MAX_ENVELOPE_BYTES: usize = MAX_MODEL_BYTES + MAX_SOURCE_PROJECT_BYTES + 512;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -30,6 +38,7 @@ pub struct StoredProject {
pub resource_id: Id,
pub revision: u64,
pub model: Project,
pub source_project: SourceBundle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub migration: Option<MigrationReport>,
}
@@ -93,11 +102,88 @@ struct StoreInner {
/// Constructed only after complete candidate validation, outside the catalog
/// mutex. Commit methods cannot accidentally accept an unchecked candidate.
struct PreparedModel(Project, Option<MigrationReport>);
impl PreparedModel {
pub(crate) struct PreparedProject {
compiled: CompiledSourceProject,
migration: Option<MigrationReport>,
}
impl PreparedProject {
fn new(model: Project) -> ApiResult<Self> {
validate_model(&model)?;
Ok(Self(model, None))
let prepared = Self::from_bundle(source_project::format_bundle(&model))?;
if prepared.model() != &model {
return Err(ApiError::internal(
"Generated source does not reproduce the model.",
));
}
Ok(prepared)
}
pub(crate) fn from_bundle(bundle: SourceBundle) -> ApiResult<Self> {
check_source_size(&bundle)?;
let compiled =
source_project::compile_bundle(&bundle, SOURCE_LIMITS).map_err(source_error)?;
Self::from_compiled(compiled)
}
fn from_compiled(compiled: CompiledSourceProject) -> ApiResult<Self> {
check_model_size(&compiled.decoded.project)?;
check_source_size(&compiled.bundle)?;
Ok(Self {
compiled,
migration: None,
})
}
pub(crate) fn from_command(
current: &StoredProject,
prepared: oa_core::PreparedCommand,
) -> ApiResult<Self> {
check_model_size(&prepared.project)?;
let compiled = source_project::compile_bundle(&current.source_project, SOURCE_LIMITS)
.map_err(source_error)?;
// Model equality alone is not a source no-op: delete/recreate and
// identity swaps can change declaration ownership without changing the
// final semantic snapshot.
let compiled = source_project::update_bundle_with_identity_map(
&compiled,
&prepared.project,
&prepared.identity_map,
SOURCE_LIMITS,
)
.map_err(source_error)?;
Self::from_compiled(compiled)
}
fn replacement(current: &StoredProject, decoded: DecodeResult) -> ApiResult<Self> {
validate_model(&decoded.project)?;
let compiled = source_project::compile_bundle(&current.source_project, SOURCE_LIMITS)
.map_err(source_error)?;
let compiled = source_project::update_bundle(&compiled, &decoded.project, SOURCE_LIMITS)
.map_err(source_error)?;
let mut prepared = Self::from_compiled(compiled)?;
prepared.migration = decoded.migration;
Ok(prepared)
}
pub(crate) fn model(&self) -> &Project {
&self.compiled.decoded.project
}
pub(crate) fn bundle(&self) -> &SourceBundle {
&self.compiled.bundle
}
pub(crate) fn source_issues(&self) -> Vec<crate::error::IssueDto> {
self.compiled
.diagnostics
.iter()
.cloned()
.map(crate::error::source_issue)
.collect()
}
pub(crate) fn changed(&self, current: &StoredProject) -> bool {
self.model() != &current.model || self.bundle() != &current.source_project
}
}
@@ -130,13 +216,17 @@ impl Store {
}
pub(crate) fn create(&self, model: Project) -> ApiResult<StoredProject> {
let prepared = PreparedModel::new(model)?;
let prepared = PreparedProject::new(model)?;
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;
let mut prepared = PreparedProject::new(decoded.project)?;
prepared.migration = decoded.migration;
self.lock()?.create(prepared)
}
pub(crate) fn create_source_project(&self, bundle: SourceBundle) -> ApiResult<StoredProject> {
let prepared = PreparedProject::from_bundle(bundle)?;
self.lock()?.create(prepared)
}
pub(crate) fn replace_decoded(
@@ -145,8 +235,8 @@ impl Store {
expected: &str,
decoded: DecodeResult,
) -> ApiResult<(StoredProject, bool)> {
let mut prepared = PreparedModel::new(decoded.project)?;
prepared.1 = decoded.migration;
let current = self.checked(id, expected)?;
let prepared = PreparedProject::replacement(&current, decoded)?;
self.lock()?.replace(id, expected, prepared)
}
@@ -154,9 +244,8 @@ impl Store {
&self,
id: Id,
expected: &str,
model: Project,
prepared: PreparedProject,
) -> ApiResult<(StoredProject, bool)> {
let prepared = PreparedModel::new(model)?;
self.lock()?.replace(id, expected, prepared)
}
@@ -216,8 +305,7 @@ impl Store {
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(corrupt("Envelope exceeds the size limit.".into()));
}
total_bytes = total_bytes.saturating_add(bytes.len());
if projects.len() >= MAX_PROJECTS || total_bytes > MAX_STORE_BYTES {
if projects.len() >= MAX_PROJECTS {
return Err(StoreError::Capacity(
"At most 128 models and 256 MiB of serialized envelopes are supported.".into(),
));
@@ -228,6 +316,8 @@ impl Store {
resource_id: Id,
revision: u64,
model: Box<serde_json::value::RawValue>,
#[serde(default, deserialize_with = "present_source_project")]
source_project: Option<SourceBundle>,
}
let envelope: Envelope =
serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?;
@@ -236,10 +326,27 @@ impl Store {
MODEL_LIMITS,
)
.map_err(|error| corrupt(error.to_string()))?;
let prepared = if let Some(bundle) = envelope.source_project {
let prepared = PreparedProject::from_bundle(bundle.clone())
.map_err(|error| corrupt(error.detail))?;
if prepared.bundle() != &bundle || prepared.model() != &decoded.project {
return Err(corrupt(
"Stored source and model disagree, or source lacks stable identities."
.into(),
));
}
prepared
} else {
// Old model-only envelopes gain deterministic source in memory.
// Opening the store never saves or advances their revision.
PreparedProject::new(decoded.project.clone())
.map_err(|error| corrupt(error.detail))?
};
let stored = StoredProject {
resource_id: envelope.resource_id,
revision: envelope.revision,
model: decoded.project,
source_project: prepared.compiled.bundle,
migration: decoded.migration,
};
if stored.resource_id != filename_id || stored.revision == 0 {
@@ -247,8 +354,18 @@ impl Store {
"Resource ID/filename mismatch or zero revision.".into(),
));
}
validate_model(&stored.model).map_err(|error| corrupt(error.detail))?;
byte_sizes.insert(stored.resource_id, bytes.len());
let size = envelope_bytes(&stored)
.map_err(|error| corrupt(error.detail))?
.len();
// Capacity charges the complete accepted pair, including an old
// envelope's in-memory source upgrade before its next changed save.
total_bytes = total_bytes.saturating_add(size);
if total_bytes > MAX_STORE_BYTES {
return Err(StoreError::Capacity(
"Complete accepted envelopes exceed 256 MiB.".into(),
));
}
byte_sizes.insert(stored.resource_id, size);
if projects.insert(stored.resource_id, stored).is_some() {
return Err(corrupt("Duplicate hosted resource ID.".into()));
}
@@ -284,16 +401,16 @@ impl StoreInner {
Ok(current)
}
fn create(&mut self, prepared: PreparedModel) -> ApiResult<StoredProject> {
fn create(&mut self, prepared: PreparedProject) -> ApiResult<StoredProject> {
if self.projects.len() >= MAX_PROJECTS {
return Err(capacity_error());
}
let model = prepared.0;
let stored = StoredProject {
resource_id: Id::new_v4(),
revision: 1,
model,
migration: prepared.1,
model: prepared.compiled.decoded.project,
source_project: prepared.compiled.bundle,
migration: prepared.migration,
};
let size = self.write(&stored, true)?;
self.adopt(stored.clone(), size);
@@ -305,14 +422,13 @@ impl StoreInner {
&mut self,
id: Id,
expected: &str,
prepared: PreparedModel,
prepared: PreparedProject,
) -> ApiResult<(StoredProject, bool)> {
let current = self.checked(id, expected)?;
let model = prepared.0;
if current.model == model {
if !prepared.changed(current) {
let mut unchanged = current.clone();
if prepared.1.is_some() {
unchanged.migration = prepared.1;
if prepared.migration.is_some() {
unchanged.migration = prepared.migration;
}
return Ok((unchanged, false));
}
@@ -326,8 +442,9 @@ impl StoreInner {
let stored = StoredProject {
resource_id: id,
revision,
model,
migration: prepared.1,
model: prepared.compiled.decoded.project,
source_project: prepared.compiled.bundle,
migration: prepared.migration,
};
let size = self.write(&stored, false)?;
self.adopt(stored.clone(), size);
@@ -357,17 +474,7 @@ impl StoreInner {
}
fn write(&self, stored: &StoredProject, create: bool) -> ApiResult<usize> {
// 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."))?;
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"model_too_large",
"Stored model exceeds 8 MiB.",
));
}
let bytes = envelope_bytes(stored)?;
let previous = self
.byte_sizes
.get(&stored.resource_id)
@@ -410,6 +517,51 @@ fn capacity_error() -> ApiError {
)
}
fn present_source_project<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<SourceBundle>, D::Error> {
SourceBundle::deserialize(deserializer).map(Some)
}
fn envelope_bytes(stored: &StoredProject) -> ApiResult<Vec<u8>> {
// Migration observations are response metadata, never persisted facts.
#[derive(Serialize)]
struct Envelope<'a> {
resource_id: Id,
revision: u64,
model: &'a Project,
source_project: &'a SourceBundle,
}
let bytes = serde_json::to_vec(&Envelope {
resource_id: stored.resource_id,
revision: stored.revision,
model: &stored.model,
source_project: &stored.source_project,
})
.map_err(|_| ApiError::internal("Could not serialize accepted project."))?;
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"project_too_large",
"Stored project exceeds the envelope limit.",
));
}
Ok(bytes)
}
pub(crate) fn check_source_size(bundle: &SourceBundle) -> ApiResult<()> {
let bytes = serde_json::to_vec(bundle)
.map_err(|_| ApiError::bad_request("Source project cannot be serialized."))?;
if bytes.len() > MAX_SOURCE_PROJECT_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"source_project_too_large",
"Serialized source project exceeds 8 MiB after identity stabilization.",
));
}
Ok(())
}
pub(crate) fn check_model_size(model: &Project) -> ApiResult<()> {
MODEL_LIMITS
.check_model(model)
@@ -445,6 +597,46 @@ pub(crate) fn validate_model(model: &Project) -> ApiResult<()> {
mod tests {
use super::*;
#[test]
fn source_bytes_are_charged_on_legacy_open_update_and_delete() {
let directory = tempfile::tempdir().unwrap();
let resource_id = Id::new_v4();
let model = Project::empty();
let path = directory.path().join(format!("{resource_id}.json"));
let legacy_bytes = serde_json::to_vec(
&serde_json::json!({"resource_id":resource_id,"revision":4,"model":model}),
)
.unwrap();
fs::write(&path, &legacy_bytes).unwrap();
let store = Store::open(directory.path()).unwrap();
let current = store.get(resource_id).unwrap();
let accepted_size = envelope_bytes(&current).unwrap().len();
assert!(accepted_size > legacy_bytes.len());
assert_eq!(store.lock().unwrap().total_bytes, accepted_size);
assert_eq!(fs::read(&path).unwrap(), legacy_bytes);
let mut bundle = current.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str(&format!("// {}\n", "rationale ".repeat(512)));
let candidate = PreparedProject::from_bundle(bundle).unwrap();
let changed = store
.replace(resource_id, &current.etag(), candidate)
.unwrap()
.0;
let changed_size = envelope_bytes(&changed).unwrap().len();
assert!(changed_size > accepted_size + 5_000);
assert_eq!(store.lock().unwrap().total_bytes, changed_size);
assert_eq!(fs::read(&path).unwrap().len(), changed_size);
drop(store);
let store = Store::open(directory.path()).unwrap();
assert_eq!(store.lock().unwrap().total_bytes, changed_size);
store.delete(resource_id, &changed.etag()).unwrap();
assert_eq!(store.lock().unwrap().total_bytes, 0);
assert!(store.lock().unwrap().byte_sizes.is_empty());
}
#[test]
fn prepared_candidates_recheck_revision_at_commit_and_failed_loser_is_not_installed() {
let directory = tempfile::tempdir().unwrap();
@@ -456,8 +648,8 @@ mod tests {
winner.name = "Winning review".into();
loser.name = "Losing stale review".into();
// Both requests completed preparation against the same original head.
let winner = PreparedModel::new(winner).unwrap();
let loser = PreparedModel::new(loser).unwrap();
let winner = PreparedProject::new(winner).unwrap();
let loser = PreparedProject::new(loser).unwrap();
let committed = store
.lock()
.unwrap()
+13 -3
View File
@@ -8,6 +8,9 @@ use serde_json::{Value, json};
use std::{path::Path, sync::Arc};
use tokio::{sync::oneshot, task::JoinHandle};
#[path = "http/source_projects.rs"]
mod source_projects;
fn v2_missing_ports() -> Value {
let mut old: Value = serde_json::from_slice(include_bytes!(
"../../oa-core/tests/fixtures/v2-coastal-rescue.json"
@@ -1590,6 +1593,7 @@ async fn failed_atomic_replacement_preserves_the_accepted_model() {
let after = server.get(id).await;
assert_eq!(after.revision, before.revision);
assert_eq!(after.model, before.model);
assert_eq!(after.source_project, before.source_project);
std::fs::remove_dir(&envelope).unwrap();
std::fs::write(&envelope, bytes).unwrap();
server.stop().await;
@@ -1605,19 +1609,23 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() {
let path = directory.path().join(format!("{filename}.json"));
std::fs::write(&path, "{broken").unwrap();
assert!(Store::open(directory.path()).is_err());
let model = Project::empty();
let mismatched = StoredProject {
migration: None,
resource_id: Id::new_v4(),
revision: 1,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
let model = Project::empty();
let invalid = StoredProject {
migration: None,
resource_id: filename,
revision: 0,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
@@ -1773,11 +1781,13 @@ async fn fixed_store_capacity_is_enforced_on_http_and_restart() {
let directory = tempfile::tempdir().unwrap();
let mut last = None;
for _ in 0..oa_server::store::MAX_PROJECTS {
let model = Project::empty();
let envelope = StoredProject {
migration: None,
resource_id: Id::new_v4(),
revision: 1,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(
directory
@@ -0,0 +1,806 @@
use super::*;
use oa_core::source_project::*;
use std::collections::BTreeMap;
fn authored() -> SourceBundle {
let bundle = SourceBundle {
entry: "harbor.arcadia".into(),
files: BTreeMap::from([
("harbor.arcadia".into(), "// Harbor engineering rationale 航海\r\narcadia 3; project harbor \"Harbor rescue\" {\r\n include \"team/first.arcadia\";\r\n include \"team/second.arcadia\";\r\n include \"contracts.arcadia\";\r\n include \"work.arcadia\";\r\n}\r\n".into()),
("team/first.arcadia".into(), "// First payload rationale\r\nsa_exchange_item first \"First payload\" { description \"Reviewed first\"; }\r\n".into()),
("team/second.arcadia".into(), "// Second payload rationale\r\nsa_exchange_item second \"Second payload\" {}\r\n".into()),
("contracts.arcadia".into(), "// Contract ownership stays here\r\nsa_interface contract \"Contract\" { items [\r\n @first // first reference rationale\r\n ,\r\n @second // second reference rationale\r\n ]; }\r\n".into()),
("work.arcadia".into(), "// Operational rationale\r\noa_activity assess \"Assess assistance\" {}\r\n".into()),
]),
};
compile_bundle(&bundle, ProjectLimits::default())
.unwrap()
.bundle
}
fn id_for(model: &Project, alias: &str) -> Id {
model
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn assert_pair(stored: &StoredProject) -> CompiledSourceProject {
let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, stored.model);
assert_eq!(
compiled.bundle, stored.source_project,
"Stored IDs already stabilized"
);
compiled
}
async fn import(server: &TestServer, bundle: &SourceBundle) -> StoredProject {
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(bundle)
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::CREATED, "{body}");
server
.get(body["resource_id"].as_str().unwrap().parse().unwrap())
.await
}
async fn put(server: &TestServer, current: &StoredProject, bundle: &SourceBundle) -> Value {
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(bundle)
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{body}");
body
}
async fn transact(
server: &TestServer,
current: &StoredProject,
commands: &[Command],
dry_run: bool,
) -> Value {
let response = server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", current.etag())
.json(&json!({"commands":commands,"dry_run":dry_run}))
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{body}");
body
}
#[tokio::test]
async fn source_projects_preserve_exact_files_comments_and_comment_only_revisions() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let source = authored();
let original = import(&server, &source).await;
let id = original.resource_id;
assert_eq!(original.source_project, source);
assert_pair(&original);
let url = server.url(&format!("/projects/{id}/source-project"));
let exported = server.client.get(&url).send().await.unwrap();
assert_eq!(
exported.headers()["etag"].to_str().unwrap(),
original.etag()
);
assert_eq!(exported.json::<SourceBundle>().await.unwrap(), source);
assert_eq!(
server
.client
.get(&url)
.header("if-none-match", original.etag())
.send()
.await
.unwrap()
.status(),
StatusCode::NOT_MODIFIED
);
let mut edited = source.clone();
edited
.files
.get_mut("team/first.arcadia")
.unwrap()
.push_str("// Reviewed through automation\r\n");
let response = put(&server, &original, &edited).await;
assert_eq!(response["changed"], true);
assert_eq!(
response["source_project"],
serde_json::to_value(&edited).unwrap()
);
let current = server.get(id).await;
assert_eq!(current.revision, 2);
assert_eq!(current.model, original.model);
assert_eq!(current.source_project, edited);
let stale = server
.client
.post(server.url(&format!("/projects/{id}/transactions")))
.header("if-match", original.etag())
.json(&json!({"commands":[Command::Rename(current.model.id,"Stale change".into())]}))
.send()
.await
.unwrap();
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
let filename = directory.path().join(format!("{id}.json"));
let saved_bytes = std::fs::read(&filename).unwrap();
let no_op = put(&server, &current, &edited).await;
assert_eq!(no_op["changed"], false);
assert_eq!(no_op["revision"], 2);
assert_eq!(std::fs::read(&filename).unwrap(), saved_bytes);
let mut renamed = edited;
let text = renamed.files.remove(&renamed.entry).unwrap();
renamed.entry = "rescue.arcadia".into();
renamed.files.insert(renamed.entry.clone(), text);
assert_eq!(put(&server, &current, &renamed).await["changed"], true);
let current = server.get(id).await;
assert_eq!(current.revision, 3);
assert_eq!(current.model, original.model);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let restored = server.get(id).await;
assert_eq!(restored.source_project, renamed);
assert_eq!(restored.etag(), current.etag());
assert_pair(&restored);
server.stop().await;
}
#[tokio::test]
async fn source_projects_failed_atomic_comment_write_preserves_both_representations() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server, &authored()).await;
let id = current.resource_id;
let envelope = directory.path().join(format!("{id}.json"));
let bytes = std::fs::read(&envelope).unwrap();
let mut edited = current.source_project.clone();
edited
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("// Must not be adopted\r\n");
std::fs::remove_file(&envelope).unwrap();
std::fs::create_dir(&envelope).unwrap();
let failed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.json(&edited)
.send()
.await
.unwrap();
assert_eq!(failed.status(), StatusCode::SERVICE_UNAVAILABLE);
let unchanged = server.get(id).await;
assert_eq!(unchanged.etag(), current.etag());
assert_eq!(unchanged.model, current.model);
assert_eq!(unchanged.source_project, current.source_project);
std::fs::remove_dir(&envelope).unwrap();
std::fs::write(&envelope, &bytes).unwrap();
assert!(!std::fs::read_dir(directory.path()).unwrap().any(|entry| {
entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".arcadia-write-")
}));
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.source_project, current.source_project);
server.stop().await;
}
#[test]
fn source_projects_openapi_describes_the_coupled_envelope_and_source_diagnostics() {
let spec: Value = serde_json::from_str(include_str!("../../../../docs/openapi.json")).unwrap();
let schemas = &spec["components"]["schemas"];
assert!(
schemas["StoredProject"]["required"]
.as_array()
.unwrap()
.contains(&json!("source_project"))
);
assert_eq!(
schemas["SourceBundle"]["required"],
json!(["entry", "files"])
);
assert_eq!(schemas["SourceBundle"]["additionalProperties"], false);
assert_eq!(
schemas["SourceBundle"]["properties"]["files"]["maxProperties"],
256
);
for property in ["path", "span", "related"] {
assert!(schemas["Issue"]["properties"].get(property).is_some());
}
assert_eq!(
schemas["TransactionResult"]["properties"]["source_project"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert!(
schemas["ValidationRequest"]["oneOf"]
.as_array()
.unwrap()
.iter()
.any(|candidate| candidate["properties"]["format"]["const"] == "source-project")
);
let routes = &spec["paths"];
assert_eq!(
routes["/api/v1/projects/import-source-project"]["post"]["requestBody"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert_eq!(
routes["/api/v1/projects/{id}/source-project"]["get"]["responses"]["200"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert_eq!(
routes["/api/v1/projects/{id}/source-project"]["put"]["responses"]["200"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceProjectMutationResult"
);
}
#[tokio::test]
async fn source_projects_command_dry_runs_and_identity_swaps_preserve_lexical_ownership() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let first = id_for(&original.model, "first");
let second = id_for(&original.model, "second");
let changed_id = Id::new_v4();
let mut element = original.model.elements[&first].clone();
element.id = changed_id;
element.alias = "revised_first".into();
element.name = "Reviewed payload".into();
let commands = vec![Command::EditElement(first, element)];
let filename = directory
.path()
.join(format!("{}.json", original.resource_id));
let before_bytes = std::fs::read(&filename).unwrap();
let dry_run = transact(&server, &original, &commands, true).await;
let proposed: SourceBundle = serde_json::from_value(dry_run["source_project"].clone()).unwrap();
assert!(proposed.files["team/first.arcadia"].contains(&changed_id.to_string()));
assert!(proposed.files["team/first.arcadia"].starts_with("// First payload rationale\r\n"));
assert_eq!(
proposed.files["team/second.arcadia"],
original.source_project.files["team/second.arcadia"]
);
assert!(
proposed.files["contracts.arcadia"].contains("@revised_first // first reference rationale")
);
assert_eq!(
server.get(original.resource_id).await.source_project,
original.source_project
);
assert_eq!(std::fs::read(&filename).unwrap(), before_bytes);
transact(&server, &original, &commands, false).await;
let current = server.get(original.resource_id).await;
assert_eq!(current.source_project, proposed);
assert_eq!(
assert_pair(&current).index.declarations[&changed_id].path,
"team/first.arcadia"
);
let temporary = Id::new_v4();
let swaps = vec![Command::Batch(vec![
Command::Reidentify(changed_id, temporary),
Command::Reidentify(second, changed_id),
Command::Reidentify(temporary, second),
])];
transact(&server, &current, &swaps, false).await;
let swapped = server.get(current.resource_id).await;
let compiled = assert_pair(&swapped);
assert_eq!(
compiled.index.declarations[&second].path,
"team/first.arcadia"
);
assert_eq!(
compiled.index.declarations[&changed_id].path,
"team/second.arcadia"
);
assert!(
swapped.source_project.files["team/first.arcadia"]
.starts_with("// First payload rationale\r\n")
);
assert!(
swapped.source_project.files["contracts.arcadia"]
.contains("@revised_first // first reference rationale")
);
// Removing and reusing a UUID does not attach the old authored declaration
// and its rationale to an unrelated new record.
let mut replacement = swapped.model.elements[&second].clone();
replacement.alias = "new_payload".into();
replacement.name = "Independent new payload".into();
replacement.description.clear();
transact(
&server,
&swapped,
&[Command::Delete(second), Command::CreateElement(replacement)],
false,
)
.await;
let replaced = server.get(swapped.resource_id).await;
let compiled = assert_pair(&replaced);
assert_ne!(
compiled.index.declarations[&second].path,
"team/first.arcadia"
);
assert!(
replaced.source_project.files["team/first.arcadia"].contains("First payload rationale")
);
assert!(!replaced.source_project.files["team/first.arcadia"].contains("new_payload"));
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(
server.get(replaced.resource_id).await.source_project,
replaced.source_project
);
server.stop().await;
}
#[tokio::test]
async fn source_projects_equal_models_still_apply_changed_identity_provenance() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let work = id_for(&original.model, "assess");
let commands = [
Command::Delete(work),
Command::CreateElement(original.model.elements[&work].clone()),
];
let prepared =
oa_core::prepare_command(&original.model, &Command::Batch(commands.to_vec())).unwrap();
assert_eq!(prepared.project, original.model);
assert!(!prepared.identity_map.contains_key(&work));
let dry_run = transact(&server, &original, &commands, true).await;
assert_eq!(
dry_run["changed"], true,
"Recreated record must not inherit the deleted record's declaration ownership"
);
assert_eq!(
dry_run["model"],
serde_json::to_value(&original.model).unwrap()
);
let response = transact(&server, &original, &commands, false).await;
assert_eq!(response["changed"], true);
let changed = server.get(original.resource_id).await;
assert_eq!(changed.revision, original.revision + 1);
assert_eq!(changed.model, original.model);
let compiled = assert_pair(&changed);
assert_ne!(compiled.index.declarations[&work].path, "work.arcadia");
assert!(changed.source_project.files["work.arcadia"].contains("Operational rationale"));
assert!(!changed.source_project.files["work.arcadia"].contains("oa_activity"));
server.stop().await;
}
#[tokio::test]
async fn source_projects_model_interchange_and_transitions_keep_authored_modules() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let id = original.resource_id;
let work = id_for(&original.model, "assess");
let mut model = original.model.clone();
model.elements.get_mut(&work).unwrap().name = "Review assistance".into();
let response = server
.client
.put(server.url(&format!("/projects/{id}/model")))
.header("if-match", original.etag())
.json(&model)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let current = server.get(id).await;
assert_eq!(current.source_project.entry, "harbor.arcadia");
assert_eq!(
current.source_project.files["team/first.arcadia"],
original.source_project.files["team/first.arcadia"]
);
assert!(
current.source_project.files["work.arcadia"].starts_with("// Operational rationale\r\n")
);
model.elements.get_mut(&work).unwrap().description =
"Changed through canonical interchange".into();
let response = server
.client
.put(server.url(&format!("/projects/{id}/source")))
.header("if-match", current.etag())
.header("content-type", "text/plain")
.body(oa_core::language::format(&model))
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let current = server.get(id).await;
assert_pair(&current);
let request = oa_core::transitions::TransitionRequest::new(
oa_core::transitions::TransitionDirection::OaToSa,
vec![work],
);
let response = server
.client
.post(server.url(&format!("/projects/{id}/transitions/preview")))
.header("if-match", current.etag())
.json(&request)
.send()
.await
.unwrap();
let status = response.status();
let preview: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{preview}");
assert_eq!(server.get(id).await.source_project, current.source_project);
let response = server
.client
.post(server.url(&format!("/projects/{id}/transitions/apply")))
.header("if-match", current.etag())
.json(&preview)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let final_state = server.get(id).await;
assert_pair(&final_state);
assert!(
final_state
.model
.elements
.values()
.any(|e| e.kind() == Kind::Function)
);
assert_eq!(final_state.model.traces.len(), 1);
for path in [
"team/first.arcadia",
"team/second.arcadia",
"contracts.arcadia",
] {
assert_eq!(
final_state.source_project.files[path],
original.source_project.files[path]
);
}
server.stop().await;
}
#[tokio::test]
async fn source_projects_invalid_dependencies_and_duplicate_paths_never_change_the_head() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server, &authored()).await;
let id = current.resource_id;
let filename = directory.path().join(format!("{id}.json"));
let original_bytes = std::fs::read(&filename).unwrap();
// A same-named server file cannot satisfy a logical source include.
std::fs::write(
directory.path().join("server_only.arcadia"),
"oa_activity external \"Must not load\" {}\n",
)
.unwrap();
let mut cases = vec![];
let mut missing = current.source_project.clone();
missing
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("include \"server_only.arcadia\";\n");
cases.push((missing, "missing_include"));
let mut duplicate = current.source_project.clone();
let first = duplicate.files["team/first.arcadia"].clone();
duplicate
.files
.get_mut("team/second.arcadia")
.unwrap()
.push_str(&first);
cases.push((duplicate, "duplicate_declaration"));
let mut invalid = current.source_project.clone();
invalid
.files
.get_mut("team/second.arcadia")
.unwrap()
.push('?');
cases.push((invalid, ""));
let mut cycle = current.source_project.clone();
cycle
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("include \"harbor.arcadia\";\n");
cases.push((cycle, "include_cycle"));
let mut orphan = current.source_project.clone();
orphan
.files
.insert("orphan.arcadia".into(), "// not included\n".into());
cases.push((orphan, "unreachable_file"));
let mut escaped = current.source_project.clone();
escaped
.files
.insert("../outside.arcadia".into(), String::new());
cases.push((escaped, "invalid_path"));
let mut excessive = current.source_project.clone();
for n in 0..256 {
excessive
.files
.insert(format!("empty{n}.arcadia"), String::new());
}
cases.push((excessive, "project_limit"));
for (bundle, code) in cases {
let response = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let error: Value = response.json().await.unwrap();
let issue = &error["issues"][0];
if !code.is_empty() {
assert_eq!(issue["code"], code, "{error}");
}
assert!(issue["path"].is_string(), "{error}");
assert!(issue["span"]["start"].is_number(), "{error}");
assert!(issue["line"].as_u64().unwrap() >= 1);
if code == "duplicate_declaration" {
assert_eq!(issue["path"], "team/second.arcadia");
assert_eq!(issue["related"][0]["path"], "team/first.arcadia");
}
let response = server
.client
.post(server.url(&format!("/projects/{id}/validation")))
.json(&json!({"format":"source-project","source_project":bundle}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let validation: Value = response.json().await.unwrap();
assert_eq!(validation["valid"], false, "{validation}");
assert_eq!(validation["issues"][0]["path"], issue["path"]);
assert_eq!(server.get(id).await.source_project, current.source_project);
assert_eq!(std::fs::read(&filename).unwrap(), original_bytes);
}
let body = "{\"entry\":\"a.arcadia\",\"files\":{\"a.arcadia\":\"one\",\"a.arcadia\":\"two\"}}";
let duplicate_keys = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.header("content-type", "application/json")
.body(body)
.send()
.await
.unwrap();
assert_eq!(duplicate_keys.status(), StatusCode::BAD_REQUEST);
assert_eq!(std::fs::read(&filename).unwrap(), original_bytes);
let missing_precondition = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.json(&current.source_project)
.send()
.await
.unwrap();
assert_eq!(
missing_precondition.status(),
StatusCode::PRECONDITION_REQUIRED
);
server.stop().await;
}
#[tokio::test]
async fn source_projects_stabilize_once_and_enforce_size_after_insertions() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let bundle = SourceBundle {
entry: "entry.arcadia".into(),
files: BTreeMap::from([
(
"entry.arcadia".into(),
"// Header rationale\narcadia 3; project p \"P\" { include \"work.arcadia\"; }\n"
.into(),
),
(
"work.arcadia".into(),
"// Work rationale\noa_activity work \"Work\" {}\n".into(),
),
]),
};
let current = import(&server, &bundle).await;
assert_ne!(current.source_project, bundle);
assert!(current.source_project.files["work.arcadia"].starts_with("// Work rationale\n"));
assert_pair(&current);
assert_eq!(
put(&server, &current, &current.source_project).await["changed"],
false
);
let mut large = SourceBundle {
entry: "entry.arcadia".into(),
files: BTreeMap::from([(
"entry.arcadia".into(),
"arcadia 3; project p \"P\" {}\n// ".into(),
)]),
};
let base = serde_json::to_vec(&large).unwrap().len();
large
.files
.get_mut("entry.arcadia")
.unwrap()
.push_str(&"x".repeat(oa_server::store::MAX_SOURCE_PROJECT_BYTES - base - 8));
assert!(serde_json::to_vec(&large).unwrap().len() < oa_server::store::MAX_MODEL_BYTES);
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(&large)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
let error: Value = response.json().await.unwrap();
assert_eq!(error["code"], "source_project_too_large");
assert_eq!(
server.get(current.resource_id).await.source_project,
current.source_project
);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let restored = server.get(current.resource_id).await;
assert_eq!(restored.source_project, current.source_project);
assert_eq!(restored.etag(), current.etag());
server.stop().await;
}
#[tokio::test]
async fn source_projects_concurrent_comment_and_semantic_writes_share_one_revision() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let id = original.resource_id;
let mut bundle = original.source_project.clone();
bundle
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("// Comment writer\r\n");
let comment = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", original.etag())
.json(&bundle)
.send();
let semantic = server
.client
.patch(server.url(&format!("/projects/{id}")))
.header("if-match", original.etag())
.json(&json!({"name":"Semantic writer"}))
.send();
let (comment, semantic) = tokio::join!(comment, semantic);
let statuses = [comment.unwrap().status(), semantic.unwrap().status()];
assert_eq!(statuses.iter().filter(|s| **s == StatusCode::OK).count(), 1);
assert_eq!(
statuses
.iter()
.filter(|s| **s == StatusCode::PRECONDITION_FAILED)
.count(),
1
);
let accepted = server.get(id).await;
assert_eq!(accepted.revision, 2);
assert_pair(&accepted);
if accepted.model.name == "Semantic writer" {
assert_eq!(
accepted.source_project.files["work.arcadia"],
original.source_project.files["work.arcadia"]
);
} else {
assert_eq!(accepted.model, original.model);
assert_eq!(accepted.source_project, bundle);
}
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.source_project, accepted.source_project);
server.stop().await;
}
#[tokio::test]
async fn source_projects_upgrade_old_envelopes_without_writes_and_reject_inconsistent_pairs() {
let directory = tempfile::tempdir().unwrap();
let resource = Id::new_v4();
let model = graph();
let filename = directory.path().join(format!("{resource}.json"));
let original =
serde_json::to_vec(&json!({"resource_id":resource,"revision":9,"model":model})).unwrap();
std::fs::write(&filename, &original).unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = server.get(resource).await;
assert_eq!(current.model, model);
assert_eq!(current.revision, 9);
assert_pair(&current);
assert_eq!(std::fs::read(&filename).unwrap(), original);
let response = server
.client
.put(server.url(&format!("/projects/{resource}/model")))
.header("if-match", current.etag())
.json(&model)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json::<Value>().await.unwrap()["changed"], false);
assert_eq!(std::fs::read(&filename).unwrap(), original);
let mut bundle = current.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("// Reviewed source upgrade\n");
put(&server, &current, &bundle).await;
let accepted = server.get(resource).await;
assert_eq!(accepted.revision, 10);
server.stop().await;
let saved = std::fs::read(&filename).unwrap();
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
assert!(envelope.get("source_project").is_some());
envelope["model"]["name"] = json!("Corrupted cache");
std::fs::write(&filename, serde_json::to_vec(&envelope).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
envelope["source_project"] = Value::Null;
std::fs::write(&filename, serde_json::to_vec(&envelope).unwrap()).unwrap();
assert!(
Store::open(directory.path()).is_err(),
"Present null source is not a legacy envelope"
);
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
let entry = envelope["source_project"]["entry"]
.as_str()
.unwrap()
.to_owned();
let header_id = format!(" id \"{}\"", accepted.model.id);
let source = envelope["source_project"]["files"][&entry]
.as_str()
.unwrap();
assert!(source.contains(&header_id));
envelope["source_project"]["files"][&entry] = json!(source.replacen(&header_id, "", 1));
let unstable_bytes = serde_json::to_vec(&envelope).unwrap();
std::fs::write(&filename, &unstable_bytes).unwrap();
assert!(
Store::open(directory.path()).is_err(),
"Accepted sources must not silently acquire new IDs on restart"
);
assert_eq!(std::fs::read(&filename).unwrap(), unstable_bytes);
std::fs::write(&filename, &saved).unwrap();
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(resource).await.source_project, bundle);
server.stop().await;
}