server: compose Workspace config schemas
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use config_source::{
|
||||
ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment, ToolchainContract,
|
||||
VirtualPath,
|
||||
ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult,
|
||||
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
||||
};
|
||||
use serde_wasm_bindgen::{Serializer, from_value};
|
||||
use std::cell::RefCell;
|
||||
@@ -10,6 +10,12 @@ thread_local! {
|
||||
static SESSION: RefCell<Option<ConfigTreeSnapshot>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn compose_schema_bundle(contributions: JsValue) -> Result<JsValue, JsValue> {
|
||||
let contributions: Vec<ConfigSchemaContribution> = decode(contributions)?;
|
||||
encode(WorkspaceConfigSchemaBundle::compose(contributions).map_err(js_error)?)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> {
|
||||
let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
|
||||
|
||||
+413
-17
@@ -9,10 +9,14 @@ use decodal_language_service::{CompletionResult, LanguageService};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 1;
|
||||
pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 2;
|
||||
pub const DECODAL_VERSION: &str = "0.2.0";
|
||||
pub const DEFAULT_SCHEMA_VERSION: u32 = 1;
|
||||
pub const DEFAULT_IMPORT_POLICY_VERSION: u32 = 1;
|
||||
pub const WORKSPACE_CONFIG_SCHEMA_GLOBAL: &str = "WorkspaceConfigSchema";
|
||||
pub const WORKSPACE_CONFIG_SCHEMA_SOURCE: &str = "workspace-config-schema.dcdl";
|
||||
pub const WORKSPACE_CONFIG_EVALUATION_SOURCE: &str =
|
||||
"WorkspaceConfigSchema & import \"__MAIN_ENTRYPOINT__\"";
|
||||
pub const MAX_ENTRY_COUNT: usize = 256;
|
||||
pub const MAX_CHANGE_COUNT: usize = 256;
|
||||
pub const MAX_ENTRY_BYTES: usize = 256 * 1024;
|
||||
@@ -321,6 +325,151 @@ impl ConfigTreeChange {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
pub struct ConfigSchemaContribution {
|
||||
pub provider_id: String,
|
||||
pub namespace: String,
|
||||
pub version: String,
|
||||
pub source: String,
|
||||
pub source_digest: String,
|
||||
}
|
||||
|
||||
impl ConfigSchemaContribution {
|
||||
pub fn new(
|
||||
provider_id: impl Into<String>,
|
||||
namespace: impl Into<String>,
|
||||
version: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
) -> Result<Self, ConfigTreeError> {
|
||||
let provider_id = provider_id.into();
|
||||
let namespace = namespace.into();
|
||||
let version = version.into();
|
||||
let source = source.into();
|
||||
if provider_id.trim().is_empty() {
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(
|
||||
"provider_id must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if namespace.trim().is_empty() {
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(
|
||||
"namespace must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if version.trim().is_empty() {
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(
|
||||
"version must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if source.trim().is_empty() {
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(
|
||||
"schema source must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
provider_id,
|
||||
namespace,
|
||||
version,
|
||||
source_digest: digest_bytes(source.as_bytes()),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), ConfigTreeError> {
|
||||
let expected = digest_bytes(self.source.as_bytes());
|
||||
if self.source_digest != expected {
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(format!(
|
||||
"schema contribution {} digest mismatch",
|
||||
self.provider_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
pub struct WorkspaceConfigSchemaBundle {
|
||||
pub contributions: Vec<ConfigSchemaContribution>,
|
||||
pub source: String,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigSchemaBundle {
|
||||
pub fn compose(
|
||||
contributions: impl IntoIterator<Item = ConfigSchemaContribution>,
|
||||
) -> Result<Self, ConfigTreeError> {
|
||||
let mut contributions = contributions.into_iter().collect::<Vec<_>>();
|
||||
contributions.sort_by(|left, right| left.provider_id.cmp(&right.provider_id));
|
||||
for contribution in &contributions {
|
||||
contribution.validate()?;
|
||||
}
|
||||
for pair in contributions.windows(2) {
|
||||
if pair[0].provider_id == pair[1].provider_id {
|
||||
return Err(ConfigTreeError::DuplicateSchemaProvider(
|
||||
pair[0].provider_id.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut namespaces = std::collections::BTreeSet::new();
|
||||
for contribution in &contributions {
|
||||
if !namespaces.insert(contribution.namespace.clone()) {
|
||||
return Err(ConfigTreeError::DuplicateSchemaNamespace(
|
||||
contribution.namespace.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let source = if contributions.is_empty() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
contributions
|
||||
.iter()
|
||||
.map(|contribution| format!("({})", contribution.source))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ")
|
||||
};
|
||||
let fingerprint = digest_bytes(
|
||||
serde_json::to_vec(&(
|
||||
CONFIG_SOURCE_CONTRACT_VERSION,
|
||||
DECODAL_VERSION,
|
||||
contributions
|
||||
.iter()
|
||||
.map(|contribution| {
|
||||
(
|
||||
contribution.provider_id.as_str(),
|
||||
contribution.namespace.as_str(),
|
||||
contribution.version.as_str(),
|
||||
contribution.source_digest.as_str(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
digest_bytes(source.as_bytes()),
|
||||
))
|
||||
.expect("schema bundle fingerprint input serializes")
|
||||
.as_slice(),
|
||||
);
|
||||
Ok(Self {
|
||||
contributions,
|
||||
source,
|
||||
fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self::compose(Vec::new()).expect("empty schema bundle is valid")
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ConfigTreeError> {
|
||||
let recomposed = Self::compose(self.contributions.clone())?;
|
||||
if recomposed.source != self.source || recomposed.fingerprint != self.fingerprint {
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(
|
||||
"workspace config schema bundle fingerprint mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
|
||||
pub struct ToolchainContract {
|
||||
pub contract_version: u32,
|
||||
@@ -328,14 +477,29 @@ pub struct ToolchainContract {
|
||||
pub schema_version: u32,
|
||||
pub entrypoints: Vec<VirtualPath>,
|
||||
pub import_policy_version: u32,
|
||||
pub schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
impl ToolchainContract {
|
||||
pub fn new(
|
||||
schema_version: u32,
|
||||
entrypoints: Vec<VirtualPath>,
|
||||
import_policy_version: u32,
|
||||
) -> Self {
|
||||
Self::with_schema_bundle(
|
||||
schema_version,
|
||||
entrypoints,
|
||||
import_policy_version,
|
||||
WorkspaceConfigSchemaBundle::empty(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_schema_bundle(
|
||||
schema_version: u32,
|
||||
mut entrypoints: Vec<VirtualPath>,
|
||||
import_policy_version: u32,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Self {
|
||||
entrypoints.sort();
|
||||
entrypoints.dedup();
|
||||
@@ -345,6 +509,7 @@ impl ToolchainContract {
|
||||
schema_version,
|
||||
entrypoints,
|
||||
import_policy_version,
|
||||
schema_bundle,
|
||||
fingerprint: String::new(),
|
||||
};
|
||||
contract.fingerprint = digest_bytes(
|
||||
@@ -354,12 +519,32 @@ impl ToolchainContract {
|
||||
contract.schema_version,
|
||||
&contract.entrypoints,
|
||||
contract.import_policy_version,
|
||||
&contract.schema_bundle.fingerprint,
|
||||
))
|
||||
.expect("toolchain contract serializes")
|
||||
.as_slice(),
|
||||
);
|
||||
contract
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ConfigTreeError> {
|
||||
self.schema_bundle.validate()?;
|
||||
let expected = Self::with_schema_bundle(
|
||||
self.schema_version,
|
||||
self.entrypoints.clone(),
|
||||
self.import_policy_version,
|
||||
self.schema_bundle.clone(),
|
||||
);
|
||||
if self.contract_version != expected.contract_version
|
||||
|| self.decodal_version != expected.decodal_version
|
||||
|| self.fingerprint != expected.fingerprint
|
||||
{
|
||||
return Err(ConfigTreeError::InvalidSchemaContribution(
|
||||
"toolchain contract fingerprint mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
|
||||
@@ -420,6 +605,13 @@ impl SnapshotEnvironment {
|
||||
&self,
|
||||
contract: &ToolchainContract,
|
||||
) -> Result<EvaluationResult, Vec<ConfigDiagnostic>> {
|
||||
if let Err(error) = contract.validate() {
|
||||
return Err(vec![self.config_error(
|
||||
VirtualPath::parse(WORKSPACE_CONFIG_SCHEMA_SOURCE).expect("schema path is valid"),
|
||||
"schema_contract",
|
||||
&error.to_string(),
|
||||
)]);
|
||||
}
|
||||
let service = LanguageService::new(self);
|
||||
let diagnostics = self
|
||||
.snapshot
|
||||
@@ -440,8 +632,19 @@ impl SnapshotEnvironment {
|
||||
if !diagnostics.is_empty() {
|
||||
return Err(diagnostics);
|
||||
}
|
||||
let mut projections = Vec::new();
|
||||
for entrypoint in &contract.entrypoints {
|
||||
let Some(entrypoint) = contract.entrypoints.first() else {
|
||||
return Ok(EvaluationResult {
|
||||
projections: Vec::new(),
|
||||
projection_digest: digest_bytes(b"[]"),
|
||||
});
|
||||
};
|
||||
if contract.entrypoints.len() != 1 {
|
||||
return Err(vec![self.config_error(
|
||||
entrypoint.clone(),
|
||||
"entrypoint_count",
|
||||
"Workspace config evaluation requires exactly one entrypoint",
|
||||
)]);
|
||||
}
|
||||
let Some(entry) = self.snapshot.get(entrypoint) else {
|
||||
return Err(vec![self.config_error(
|
||||
entrypoint.clone(),
|
||||
@@ -456,29 +659,78 @@ impl SnapshotEnvironment {
|
||||
"configured entrypoint is not Decodal source",
|
||||
)]);
|
||||
}
|
||||
match service.evaluate(entrypoint.as_str(), entrypoint.as_str(), &entry.content) {
|
||||
Ok(data) => {
|
||||
let mut engine = decodal::Engine::new(SnapshotImportLoader {
|
||||
snapshot: self.snapshot.clone(),
|
||||
});
|
||||
let schema_module = engine
|
||||
.add_root_source(
|
||||
WORKSPACE_CONFIG_SCHEMA_SOURCE,
|
||||
WORKSPACE_CONFIG_SCHEMA_SOURCE,
|
||||
&contract.schema_bundle.source,
|
||||
)
|
||||
.map_err(|diagnostic| {
|
||||
vec![project_engine_diagnostic(
|
||||
&engine,
|
||||
&self.snapshot,
|
||||
VirtualPath::parse(WORKSPACE_CONFIG_SCHEMA_SOURCE)
|
||||
.expect("schema path is valid"),
|
||||
&diagnostic,
|
||||
)]
|
||||
})?;
|
||||
let schema = engine.eval_module(schema_module).map_err(|diagnostic| {
|
||||
vec![project_engine_diagnostic(
|
||||
&engine,
|
||||
&self.snapshot,
|
||||
VirtualPath::parse(WORKSPACE_CONFIG_SCHEMA_SOURCE).expect("schema path is valid"),
|
||||
&diagnostic,
|
||||
)]
|
||||
})?;
|
||||
engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema);
|
||||
let evaluation_source =
|
||||
WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str());
|
||||
let evaluation_module = engine
|
||||
.add_root_source(
|
||||
"workspace-config-evaluation.dcdl",
|
||||
"workspace-config-evaluation.dcdl",
|
||||
&evaluation_source,
|
||||
)
|
||||
.map_err(|diagnostic| {
|
||||
vec![project_engine_diagnostic(
|
||||
&engine,
|
||||
&self.snapshot,
|
||||
entrypoint.clone(),
|
||||
&diagnostic,
|
||||
)]
|
||||
})?;
|
||||
let value = engine
|
||||
.eval_module(evaluation_module)
|
||||
.map_err(|diagnostic| {
|
||||
vec![project_engine_diagnostic(
|
||||
&engine,
|
||||
&self.snapshot,
|
||||
entrypoint.clone(),
|
||||
&diagnostic,
|
||||
)]
|
||||
})?;
|
||||
let data = engine.materialize(&value).map_err(|diagnostic| {
|
||||
vec![project_engine_diagnostic(
|
||||
&engine,
|
||||
&self.snapshot,
|
||||
entrypoint.clone(),
|
||||
&diagnostic,
|
||||
)]
|
||||
})?;
|
||||
let data_json = decodal_data_to_json(&data);
|
||||
let projection_digest = digest_bytes(
|
||||
serde_json::to_vec(&data_json)
|
||||
.expect("Decodal projection serializes")
|
||||
.as_slice(),
|
||||
);
|
||||
projections.push(EvaluatedProjection {
|
||||
let projections = vec![EvaluatedProjection {
|
||||
entrypoint: entrypoint.clone(),
|
||||
data_json,
|
||||
projection_digest,
|
||||
});
|
||||
}
|
||||
Err(diagnostic) => {
|
||||
return Err(vec![project_diagnostic(
|
||||
&self.snapshot,
|
||||
entrypoint.clone(),
|
||||
&diagnostic,
|
||||
)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
let projection_digest = digest_bytes(
|
||||
serde_json::to_vec(&projections)
|
||||
.expect("projection set serializes")
|
||||
@@ -692,6 +944,42 @@ pub fn import_completions(
|
||||
candidates.into_iter().collect()
|
||||
}
|
||||
|
||||
fn project_engine_diagnostic(
|
||||
engine: &decodal::Engine<SnapshotImportLoader>,
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
fallback_path: VirtualPath,
|
||||
diagnostic: &Diagnostic,
|
||||
) -> ConfigDiagnostic {
|
||||
let path = engine
|
||||
.source_name(diagnostic.span.source)
|
||||
.and_then(|name| VirtualPath::parse(name).ok())
|
||||
.filter(|path| snapshot.entries.contains_key(path))
|
||||
.unwrap_or(fallback_path);
|
||||
ConfigDiagnostic {
|
||||
path,
|
||||
revision: snapshot.revision,
|
||||
tree_digest: snapshot.digest.clone(),
|
||||
span: ConfigSpan {
|
||||
start_byte: diagnostic.span.start,
|
||||
end_byte: diagnostic.span.end,
|
||||
},
|
||||
kind: format!("{:?}", diagnostic.kind).to_ascii_lowercase(),
|
||||
message: diagnostic.message.clone(),
|
||||
labels: diagnostic
|
||||
.labels
|
||||
.iter()
|
||||
.map(|label| ConfigDiagnosticLabel {
|
||||
span: ConfigSpan {
|
||||
start_byte: label.span.start,
|
||||
end_byte: label.span.end,
|
||||
},
|
||||
message: label.message.clone(),
|
||||
})
|
||||
.collect(),
|
||||
notes: diagnostic.notes.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_diagnostic(
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
fallback_path: VirtualPath,
|
||||
@@ -807,6 +1095,12 @@ pub enum ConfigTreeError {
|
||||
PathChangedMoreThanOnce(VirtualPath),
|
||||
#[error("duplicate virtual config path")]
|
||||
DuplicatePath,
|
||||
#[error("duplicate Workspace config schema provider: {0}")]
|
||||
DuplicateSchemaProvider(String),
|
||||
#[error("duplicate Workspace config schema namespace owner: {0}")]
|
||||
DuplicateSchemaNamespace(String),
|
||||
#[error("invalid Workspace config schema contribution: {0}")]
|
||||
InvalidSchemaContribution(String),
|
||||
#[error("virtual config limit exceeded: {0}")]
|
||||
LimitExceeded(&'static str),
|
||||
}
|
||||
@@ -832,6 +1126,8 @@ mod tests {
|
||||
export!(ConfigEntry);
|
||||
export!(ConfigTreeSnapshot);
|
||||
export!(ConfigTreeChange);
|
||||
export!(ConfigSchemaContribution);
|
||||
export!(WorkspaceConfigSchemaBundle);
|
||||
export!(ToolchainContract);
|
||||
export!(ConfigSpan);
|
||||
export!(ConfigDiagnosticLabel);
|
||||
@@ -937,6 +1233,106 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_bundle_is_order_independent_and_rejects_duplicate_provider() {
|
||||
let web = ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = { enabled = Bool default false; }; }",
|
||||
)
|
||||
.unwrap();
|
||||
let tickets = ConfigSchemaContribution::new(
|
||||
"builtin:tickets",
|
||||
"tickets",
|
||||
"1",
|
||||
"{ tickets = { enabled = Bool default true; }; }",
|
||||
)
|
||||
.unwrap();
|
||||
let left = WorkspaceConfigSchemaBundle::compose([web.clone(), tickets.clone()]).unwrap();
|
||||
let right = WorkspaceConfigSchemaBundle::compose([tickets, web.clone()]).unwrap();
|
||||
assert_eq!(left, right);
|
||||
assert!(matches!(
|
||||
WorkspaceConfigSchemaBundle::compose([web.clone(), web.clone()]),
|
||||
Err(ConfigTreeError::DuplicateSchemaProvider(provider)) if provider == "builtin:web"
|
||||
));
|
||||
let conflicting_namespace = ConfigSchemaContribution::new(
|
||||
"project:web-extension",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = { extension = true; }; }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
WorkspaceConfigSchemaBundle::compose([web.clone(), conflicting_namespace]),
|
||||
Err(ConfigTreeError::DuplicateSchemaNamespace(namespace)) if namespace == "web"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_schema_is_applied_with_normal_decodal_composition() {
|
||||
let snapshot =
|
||||
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; custom = 42; }")])
|
||||
.unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = { enabled = Bool default false; }; }",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
|
||||
let result = SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&contract)
|
||||
.unwrap();
|
||||
assert_eq!(result.projections[0].data_json["web"]["enabled"], false);
|
||||
assert_eq!(result.projections[0].data_json["custom"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_schema_type_mismatch_is_a_decodal_diagnostic() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
1,
|
||||
[entry("main.dcdl", "{ web = { enabled = 1; }; }")],
|
||||
)
|
||||
.unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = { enabled = Bool default false; }; }",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let diagnostics = SnapshotEnvironment::new(snapshot)
|
||||
.evaluate_contract(&ToolchainContract::with_schema_bundle(
|
||||
1,
|
||||
vec![path("main.dcdl")],
|
||||
1,
|
||||
schema,
|
||||
))
|
||||
.unwrap_err();
|
||||
assert_eq!(diagnostics[0].kind, "constraintviolation");
|
||||
assert!(!diagnostics[0].message.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_bundle_changes_toolchain_fingerprint() {
|
||||
let empty = ToolchainContract::new(1, vec![path("main.dcdl")], 1);
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = {}; }",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let configured =
|
||||
ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
|
||||
assert_ne!(empty.fingerprint, configured.fingerprint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_environment_evaluation_uses_only_snapshot_imports() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use config_source::{
|
||||
ConfigContentType, ConfigEntry, ConfigTreeChange, ConfigTreeSnapshot, DECODAL_VERSION,
|
||||
DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult, SnapshotEnvironment,
|
||||
ToolchainContract, VirtualPath,
|
||||
ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot,
|
||||
DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult,
|
||||
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
||||
};
|
||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -16,14 +16,50 @@ fn main_config_path() -> VirtualPath {
|
||||
VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).expect("main config entrypoint is a valid path")
|
||||
}
|
||||
|
||||
fn main_config_contract() -> ToolchainContract {
|
||||
ToolchainContract::new(
|
||||
pub trait WorkspaceConfigSchemaProvider: Send + Sync {
|
||||
fn contribution(&self) -> Result<ConfigSchemaContribution>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct WorkspaceConfigSchemaRegistry {
|
||||
providers: Vec<std::sync::Arc<dyn WorkspaceConfigSchemaProvider>>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigSchemaRegistry {
|
||||
pub fn with_provider(
|
||||
mut self,
|
||||
provider: std::sync::Arc<dyn WorkspaceConfigSchemaProvider>,
|
||||
) -> Self {
|
||||
self.providers.push(provider);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn compose(&self) -> Result<WorkspaceConfigSchemaBundle> {
|
||||
WorkspaceConfigSchemaBundle::compose(
|
||||
self.providers
|
||||
.iter()
|
||||
.map(|provider| provider.contribution())
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
)
|
||||
.map_err(config_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn main_config_contract_with_schema(
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> ToolchainContract {
|
||||
ToolchainContract::with_schema_bundle(
|
||||
DEFAULT_SCHEMA_VERSION,
|
||||
vec![main_config_path()],
|
||||
DEFAULT_IMPORT_POLICY_VERSION,
|
||||
schema_bundle,
|
||||
)
|
||||
}
|
||||
|
||||
fn main_config_contract() -> ToolchainContract {
|
||||
main_config_contract_with_schema(WorkspaceConfigSchemaBundle::empty())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
|
||||
#[ts(export)]
|
||||
pub struct WorkspaceConfigState {
|
||||
@@ -127,10 +163,11 @@ impl SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn evaluate_workspace_config_candidate(
|
||||
pub fn evaluate_workspace_config_candidate_with_schema(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
request: &ConfigCommitRequest,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
let current = self
|
||||
.load_workspace_config(workspace_id)?
|
||||
@@ -144,14 +181,39 @@ impl SqliteWorkspaceStore {
|
||||
current.snapshot.revision
|
||||
)));
|
||||
}
|
||||
let expected_contract = main_config_contract();
|
||||
let expected_contract = main_config_contract_with_schema(schema_bundle.clone());
|
||||
if expected_contract.fingerprint != request.toolchain_fingerprint {
|
||||
return Err(config_conflict(format!(
|
||||
"toolchain fingerprint mismatch; current fingerprint is {}",
|
||||
expected_contract.fingerprint
|
||||
)));
|
||||
}
|
||||
evaluate_candidate(current, &request.changes)
|
||||
evaluate_candidate(current, &request.changes, schema_bundle)
|
||||
}
|
||||
|
||||
pub fn evaluate_workspace_config_candidate(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
request: &ConfigCommitRequest,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
self.evaluate_workspace_config_candidate_with_schema(
|
||||
workspace_id,
|
||||
request,
|
||||
WorkspaceConfigSchemaBundle::empty(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn preview_workspace_config_with_schema(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
request: &ConfigPreviewRequest,
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
validate_entrypoint_request(&request.entrypoints)?;
|
||||
let current = self
|
||||
.load_workspace_config(workspace_id)?
|
||||
.ok_or_else(config_not_materialized)?;
|
||||
evaluate_candidate(current, &request.changes, schema_bundle)
|
||||
}
|
||||
|
||||
pub fn preview_workspace_config(
|
||||
@@ -159,11 +221,11 @@ impl SqliteWorkspaceStore {
|
||||
workspace_id: &str,
|
||||
request: &ConfigPreviewRequest,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
validate_entrypoint_request(&request.entrypoints)?;
|
||||
let current = self
|
||||
.load_workspace_config(workspace_id)?
|
||||
.ok_or_else(config_not_materialized)?;
|
||||
evaluate_candidate(current, &request.changes)
|
||||
self.preview_workspace_config_with_schema(
|
||||
workspace_id,
|
||||
request,
|
||||
WorkspaceConfigSchemaBundle::empty(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn commit_evaluated_workspace_config(
|
||||
@@ -197,9 +259,9 @@ impl SqliteWorkspaceStore {
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspace_config_trees (
|
||||
workspace_id, revision, tree_digest, schema_version, entrypoints_json,
|
||||
decodal_version, import_policy_version, toolchain_fingerprint,
|
||||
projection_digest, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
decodal_version, import_policy_version, schema_bundle_json,
|
||||
toolchain_fingerprint, projection_digest, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
revision = excluded.revision,
|
||||
tree_digest = excluded.tree_digest,
|
||||
@@ -207,6 +269,7 @@ impl SqliteWorkspaceStore {
|
||||
entrypoints_json = excluded.entrypoints_json,
|
||||
decodal_version = excluded.decodal_version,
|
||||
import_policy_version = excluded.import_policy_version,
|
||||
schema_bundle_json = excluded.schema_bundle_json,
|
||||
toolchain_fingerprint = excluded.toolchain_fingerprint,
|
||||
projection_digest = excluded.projection_digest,
|
||||
updated_at = excluded.updated_at"#,
|
||||
@@ -219,6 +282,8 @@ impl SqliteWorkspaceStore {
|
||||
.map_err(|error| Error::Store(error.to_string()))?,
|
||||
candidate.contract.decodal_version,
|
||||
candidate.contract.import_policy_version,
|
||||
serde_json::to_string(&candidate.contract.schema_bundle)
|
||||
.map_err(|error| Error::Store(error.to_string()))?,
|
||||
candidate.contract.fingerprint,
|
||||
candidate.evaluation.projection_digest,
|
||||
now,
|
||||
@@ -247,13 +312,15 @@ impl SqliteWorkspaceStore {
|
||||
tx.execute(
|
||||
r#"INSERT INTO workspace_config_tree_revisions (
|
||||
workspace_id, revision, tree_digest, toolchain_fingerprint,
|
||||
projection_digest, manifest_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
||||
schema_bundle_json, projection_digest, manifest_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#,
|
||||
params![
|
||||
workspace_id,
|
||||
next_revision as i64,
|
||||
snapshot.digest,
|
||||
candidate.contract.fingerprint,
|
||||
serde_json::to_string(&candidate.contract.schema_bundle)
|
||||
.map_err(|error| Error::Store(error.to_string()))?,
|
||||
candidate.evaluation.projection_digest,
|
||||
manifest_json,
|
||||
now,
|
||||
@@ -281,11 +348,12 @@ impl SqliteWorkspaceStore {
|
||||
fn evaluate_candidate(
|
||||
current: WorkspaceConfigState,
|
||||
changes: &[ConfigTreeChange],
|
||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||
) -> Result<EvaluatedConfigCandidate> {
|
||||
reject_main_entrypoint_mutation(changes)?;
|
||||
let snapshot = current.snapshot.apply(changes).map_err(config_error)?;
|
||||
ensure_main_entrypoint(&snapshot)?;
|
||||
let contract = main_config_contract();
|
||||
let contract = main_config_contract_with_schema(schema_bundle);
|
||||
let evaluation = SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
.map_err(|diagnostics| {
|
||||
@@ -307,10 +375,19 @@ pub(crate) fn load_state(
|
||||
conn: &rusqlite::Connection,
|
||||
workspace_id: &str,
|
||||
) -> Result<Option<WorkspaceConfigState>> {
|
||||
let header = conn
|
||||
.query_row(
|
||||
let has_schema_bundle: bool = conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM pragma_table_info('workspace_config_trees')
|
||||
WHERE name = 'schema_bundle_json'
|
||||
)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let header = if has_schema_bundle {
|
||||
conn.query_row(
|
||||
r#"SELECT revision, tree_digest, schema_version, entrypoints_json,
|
||||
decodal_version, import_policy_version, toolchain_fingerprint, projection_digest
|
||||
decodal_version, import_policy_version, schema_bundle_json,
|
||||
toolchain_fingerprint, projection_digest
|
||||
FROM workspace_config_trees WHERE workspace_id = ?1"#,
|
||||
[workspace_id],
|
||||
|row| {
|
||||
@@ -321,12 +398,36 @@ pub(crate) fn load_state(
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, u32>(5)?,
|
||||
Some(row.get::<_, String>(6)?),
|
||||
row.get::<_, String>(7)?,
|
||||
row.get::<_, String>(8)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?
|
||||
} else {
|
||||
conn.query_row(
|
||||
r#"SELECT revision, tree_digest, schema_version, entrypoints_json,
|
||||
decodal_version, import_policy_version,
|
||||
toolchain_fingerprint, projection_digest
|
||||
FROM workspace_config_trees WHERE workspace_id = ?1"#,
|
||||
[workspace_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, u32>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, u32>(5)?,
|
||||
None,
|
||||
row.get::<_, String>(6)?,
|
||||
row.get::<_, String>(7)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
.optional()?
|
||||
};
|
||||
let Some((
|
||||
revision,
|
||||
stored_digest,
|
||||
@@ -334,6 +435,7 @@ pub(crate) fn load_state(
|
||||
entrypoints_json,
|
||||
decodal_version,
|
||||
import_policy_version,
|
||||
schema_bundle_json,
|
||||
fingerprint,
|
||||
projection_digest,
|
||||
)) = header
|
||||
@@ -376,7 +478,17 @@ pub(crate) fn load_state(
|
||||
}
|
||||
let entrypoints: Vec<VirtualPath> = serde_json::from_str(&entrypoints_json)
|
||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?;
|
||||
let contract = ToolchainContract::new(schema_version, entrypoints, import_policy_version);
|
||||
let schema_bundle = match schema_bundle_json {
|
||||
Some(schema_bundle_json) => serde_json::from_str(&schema_bundle_json)
|
||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?,
|
||||
None => WorkspaceConfigSchemaBundle::empty(),
|
||||
};
|
||||
let contract = ToolchainContract::with_schema_bundle(
|
||||
schema_version,
|
||||
entrypoints,
|
||||
import_policy_version,
|
||||
schema_bundle,
|
||||
);
|
||||
if decodal_version != DECODAL_VERSION || contract.fingerprint != fingerprint {
|
||||
return Err(Error::RegistryInconsistency(format!(
|
||||
"virtual config toolchain metadata mismatch for Workspace {workspace_id}"
|
||||
@@ -425,6 +537,49 @@ pub(crate) fn insert_materialized_state(
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
let manifest_json = serde_json::to_string(&state.snapshot.entries)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
let has_schema_bundle: bool = tx.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM pragma_table_info('workspace_config_trees')
|
||||
WHERE name = 'schema_bundle_json'
|
||||
)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let schema_bundle_json = serde_json::to_string(&state.contract.schema_bundle)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
if has_schema_bundle {
|
||||
tx.execute(
|
||||
"INSERT INTO workspace_config_trees (
|
||||
workspace_id, revision, tree_digest, schema_version, entrypoints_json,
|
||||
decodal_version, import_policy_version, schema_bundle_json,
|
||||
toolchain_fingerprint, projection_digest, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||
ON CONFLICT(workspace_id) DO UPDATE SET
|
||||
revision = excluded.revision,
|
||||
tree_digest = excluded.tree_digest,
|
||||
schema_version = excluded.schema_version,
|
||||
entrypoints_json = excluded.entrypoints_json,
|
||||
decodal_version = excluded.decodal_version,
|
||||
import_policy_version = excluded.import_policy_version,
|
||||
schema_bundle_json = excluded.schema_bundle_json,
|
||||
toolchain_fingerprint = excluded.toolchain_fingerprint,
|
||||
projection_digest = excluded.projection_digest,
|
||||
updated_at = excluded.updated_at",
|
||||
rusqlite::params![
|
||||
workspace_id,
|
||||
state.snapshot.revision,
|
||||
state.snapshot.digest,
|
||||
state.contract.schema_version,
|
||||
entrypoints_json,
|
||||
DECODAL_VERSION,
|
||||
state.contract.import_policy_version,
|
||||
schema_bundle_json,
|
||||
state.contract.fingerprint,
|
||||
state.projection_digest,
|
||||
materialized_at,
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO workspace_config_trees (
|
||||
workspace_id, revision, tree_digest, schema_version, entrypoints_json,
|
||||
@@ -454,6 +609,7 @@ pub(crate) fn insert_materialized_state(
|
||||
materialized_at,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for entry in state.snapshot.entries.values() {
|
||||
tx.execute(
|
||||
"INSERT INTO workspace_config_entries (
|
||||
@@ -468,6 +624,24 @@ pub(crate) fn insert_materialized_state(
|
||||
],
|
||||
)?;
|
||||
}
|
||||
if has_schema_bundle {
|
||||
tx.execute(
|
||||
"INSERT INTO workspace_config_tree_revisions (
|
||||
workspace_id, revision, tree_digest, toolchain_fingerprint,
|
||||
schema_bundle_json, projection_digest, manifest_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
rusqlite::params![
|
||||
workspace_id,
|
||||
state.snapshot.revision,
|
||||
state.snapshot.digest,
|
||||
state.contract.fingerprint,
|
||||
schema_bundle_json,
|
||||
state.projection_digest,
|
||||
manifest_json,
|
||||
materialized_at,
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
tx.execute(
|
||||
"INSERT INTO workspace_config_tree_revisions (
|
||||
workspace_id, revision, tree_digest, toolchain_fingerprint,
|
||||
@@ -483,6 +657,7 @@ pub(crate) fn insert_materialized_state(
|
||||
materialized_at,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -608,6 +783,96 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_registry_applies_normal_decodal_composition() {
|
||||
struct WebSchema;
|
||||
|
||||
impl WorkspaceConfigSchemaProvider for WebSchema {
|
||||
fn contribution(&self) -> Result<ConfigSchemaContribution> {
|
||||
ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = { enabled = Bool default false; }; }",
|
||||
)
|
||||
.map_err(config_error)
|
||||
}
|
||||
}
|
||||
|
||||
let store = open_store().await;
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
|
||||
let registry =
|
||||
WorkspaceConfigSchemaRegistry::default().with_provider(std::sync::Arc::new(WebSchema));
|
||||
let schema = registry.compose().unwrap();
|
||||
let expected_contract = main_config_contract_with_schema(schema.clone());
|
||||
let candidate = store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
"w-config",
|
||||
&ConfigCommitRequest {
|
||||
base_revision: current.snapshot.revision,
|
||||
base_digest: current.snapshot.digest.clone(),
|
||||
changes: vec![ConfigTreeChange::Update {
|
||||
path: path(MAIN_CONFIG_ENTRYPOINT),
|
||||
expected_digest: main.content_digest.clone(),
|
||||
content: "{ web = {}; }".to_string(),
|
||||
}],
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
toolchain_fingerprint: expected_contract.fingerprint.clone(),
|
||||
},
|
||||
schema,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
candidate.evaluation.projections[0].data_json["web"]["enabled"],
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
candidate.contract.fingerprint,
|
||||
expected_contract.fingerprint
|
||||
);
|
||||
store
|
||||
.commit_evaluated_workspace_config("w-config", &candidate)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.load_workspace_config("w-config")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.contract
|
||||
.schema_bundle,
|
||||
expected_contract.schema_bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn commit_rejects_stale_schema_bundle_fingerprint() {
|
||||
let store = open_store().await;
|
||||
let current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"builtin:web",
|
||||
"web",
|
||||
"1",
|
||||
"{ web = {}; }",
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let error = store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
"w-config",
|
||||
&ConfigCommitRequest {
|
||||
base_revision: current.snapshot.revision,
|
||||
base_digest: current.snapshot.digest,
|
||||
changes: Vec::new(),
|
||||
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
|
||||
toolchain_fingerprint: current.contract.fingerprint,
|
||||
},
|
||||
schema,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("toolchain fingerprint mismatch"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_materializes_main_entrypoint() {
|
||||
let store = open_store().await;
|
||||
@@ -806,6 +1071,7 @@ mod tests {
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
crate::store::persist_workspace_config_schema_bundles(&conn).unwrap();
|
||||
crate::store::materialize_main_config_entrypoint(&conn).unwrap();
|
||||
let state = load_state(&conn, "legacy").unwrap().unwrap();
|
||||
assert!(
|
||||
|
||||
@@ -255,6 +255,7 @@ pub struct WorkspaceApi {
|
||||
pub(crate) config: ServerConfig,
|
||||
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
||||
config_store: Arc<crate::SqliteWorkspaceStore>,
|
||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
||||
authority: SqliteWorkspaceAuthority,
|
||||
runtime: Arc<RuntimeRegistry>,
|
||||
companion: Arc<CompanionConsole>,
|
||||
@@ -643,6 +644,14 @@ impl crate::worker_source::VerifiedWorkerRemoveExecutor for WorkspaceWorkerRemov
|
||||
}
|
||||
|
||||
impl WorkspaceApi {
|
||||
pub fn with_config_schema_provider(
|
||||
mut self,
|
||||
provider: Arc<dyn crate::config_source::WorkspaceConfigSchemaProvider>,
|
||||
) -> Self {
|
||||
self.config_schema_registry = self.config_schema_registry.with_provider(provider);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
|
||||
let resource_broker = BackendResourceBroker::default();
|
||||
let worker_remove_dispatcher = Arc::new(
|
||||
@@ -749,6 +758,7 @@ impl WorkspaceApi {
|
||||
)?);
|
||||
let api = Self {
|
||||
config_store,
|
||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry::default(),
|
||||
authority: SqliteWorkspaceAuthority::new(
|
||||
config.database_path.clone(),
|
||||
config.workspace_id.clone(),
|
||||
@@ -2528,8 +2538,11 @@ async fn scoped_preview_workspace_config_tree(
|
||||
) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
Ok(Json(
|
||||
api.config_store
|
||||
.preview_workspace_config(&path.workspace_id, &request)?,
|
||||
api.config_store.preview_workspace_config_with_schema(
|
||||
&path.workspace_id,
|
||||
&request,
|
||||
api.config_schema_registry.compose()?,
|
||||
)?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2539,9 +2552,16 @@ async fn scoped_commit_workspace_config_tree(
|
||||
Json(request): Json<ConfigCommitRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<WorkspaceConfigTreeResponse>)> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let candidate = api
|
||||
.config_store
|
||||
.evaluate_workspace_config_candidate_with_schema(
|
||||
&path.workspace_id,
|
||||
&request,
|
||||
api.config_schema_registry.compose()?,
|
||||
)?;
|
||||
let state = api
|
||||
.config_store
|
||||
.evaluate_and_commit_workspace_config(&path.workspace_id, &request)?;
|
||||
.commit_evaluated_workspace_config(&path.workspace_id, &candidate)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(WorkspaceConfigTreeResponse {
|
||||
|
||||
@@ -176,6 +176,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "materialize required main.dcdl Workspace config entrypoint",
|
||||
apply: materialize_main_config_entrypoint,
|
||||
},
|
||||
Migration {
|
||||
version: 32,
|
||||
name: "persist Workspace config schema contribution bundles",
|
||||
apply: persist_workspace_config_schema_bundles,
|
||||
},
|
||||
];
|
||||
|
||||
struct Migration {
|
||||
@@ -4598,6 +4603,42 @@ fn create_workspace_config_source_authority(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn persist_workspace_config_schema_bundles(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE workspace_config_trees
|
||||
ADD COLUMN schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}';
|
||||
ALTER TABLE workspace_config_tree_revisions
|
||||
ADD COLUMN schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}';
|
||||
"#,
|
||||
)?;
|
||||
let bundle = config_source::WorkspaceConfigSchemaBundle::empty();
|
||||
let bundle_json =
|
||||
serde_json::to_string(&bundle).map_err(|error| Error::Store(error.to_string()))?;
|
||||
let contract = config_source::ToolchainContract::with_schema_bundle(
|
||||
config_source::DEFAULT_SCHEMA_VERSION,
|
||||
vec![
|
||||
config_source::VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT)
|
||||
.map_err(|error| Error::Store(error.to_string()))?,
|
||||
],
|
||||
config_source::DEFAULT_IMPORT_POLICY_VERSION,
|
||||
bundle,
|
||||
);
|
||||
conn.execute(
|
||||
"UPDATE workspace_config_trees
|
||||
SET schema_bundle_json = ?1,
|
||||
toolchain_fingerprint = ?2",
|
||||
params![bundle_json, contract.fingerprint],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE workspace_config_tree_revisions
|
||||
SET schema_bundle_json = ?1,
|
||||
toolchain_fingerprint = ?2",
|
||||
params![bundle_json, contract.fingerprint],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_worker_mutation_source_proof_replay_guard(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
@@ -5271,7 +5312,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 31);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 32);
|
||||
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
|
||||
}
|
||||
|
||||
@@ -5304,7 +5345,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 31);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 32);
|
||||
assert!(table_exists(&conn, "flow_sources").unwrap());
|
||||
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
||||
@@ -5371,7 +5412,7 @@ INSERT INTO worker_workdir_attachment_reservations (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 31);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 32);
|
||||
let repositories_sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
||||
@@ -5551,7 +5592,7 @@ INSERT INTO workdir_registry (
|
||||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 31);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
assert!(
|
||||
!store
|
||||
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
|
||||
@@ -5568,7 +5609,7 @@ INSERT INTO workdir_registry (
|
||||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 31);
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 32);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
@@ -5661,8 +5702,8 @@ INSERT INTO workdir_registry (
|
||||
owner_account_id: None,
|
||||
display_name: "Workspace A".to_string(),
|
||||
state: "active".to_string(),
|
||||
created_at: "2026-07-31T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-31T00:00:00Z".to_string(),
|
||||
created_at: "2026-07-32T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-32T00:00:00Z".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -5673,7 +5714,7 @@ INSERT INTO workdir_registry (
|
||||
assignment_id: "assignment-1".to_string(),
|
||||
worker: RuntimeWorkerRef::new("runtime-1", "worker-1"),
|
||||
assigned_by: "user-1".to_string(),
|
||||
assigned_at: "2026-07-31T00:00:01Z".to_string(),
|
||||
assigned_at: "2026-07-32T00:00:01Z".to_string(),
|
||||
};
|
||||
let created = store
|
||||
.set_current_ticket_worker_assignment(&first, None, "event-1", "operation-1", false)
|
||||
@@ -5740,7 +5781,7 @@ INSERT INTO workdir_registry (
|
||||
assignment_id: "assignment-2".to_string(),
|
||||
worker: RuntimeWorkerRef::new("runtime-2", "worker-2"),
|
||||
assigned_by: "user-2".to_string(),
|
||||
assigned_at: "2026-07-31T00:00:02Z".to_string(),
|
||||
assigned_at: "2026-07-32T00:00:02Z".to_string(),
|
||||
..first.clone()
|
||||
};
|
||||
let replaced = store
|
||||
@@ -5779,7 +5820,7 @@ INSERT INTO workdir_registry (
|
||||
"unassign-operation-stale",
|
||||
"event-stale",
|
||||
"user-1",
|
||||
"2026-07-31T00:00:03Z",
|
||||
"2026-07-32T00:00:03Z",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(stale, Error::TicketAssignmentConflict(_)));
|
||||
@@ -5792,7 +5833,7 @@ INSERT INTO workdir_registry (
|
||||
"unassign-operation-2",
|
||||
"event-3",
|
||||
"user-2",
|
||||
"2026-07-31T00:00:03Z",
|
||||
"2026-07-32T00:00:03Z",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cleared, Some(second.clone()));
|
||||
@@ -5804,7 +5845,7 @@ INSERT INTO workdir_registry (
|
||||
"unassign-operation-2",
|
||||
"ignored-clear-event",
|
||||
"user-2",
|
||||
"2026-07-31T00:00:04Z",
|
||||
"2026-07-32T00:00:04Z",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(retried_clear, Some(second));
|
||||
@@ -5816,7 +5857,7 @@ INSERT INTO workdir_registry (
|
||||
"runtime-3",
|
||||
None,
|
||||
"sha256:reserved",
|
||||
"2026-07-31T00:00:05Z",
|
||||
"2026-07-32T00:00:05Z",
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
@@ -5843,7 +5884,7 @@ INSERT INTO workdir_registry (
|
||||
assignment_id: "assignment-3".to_string(),
|
||||
worker: RuntimeWorkerRef::new("runtime-3", "worker-3"),
|
||||
assigned_by: "runtime".to_string(),
|
||||
assigned_at: "2026-07-31T00:00:06Z".to_string(),
|
||||
assigned_at: "2026-07-32T00:00:06Z".to_string(),
|
||||
};
|
||||
let completed_reservation = store
|
||||
.set_current_ticket_worker_assignment(
|
||||
@@ -6115,7 +6156,7 @@ INSERT INTO workdir_registry (
|
||||
.unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 31);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
@@ -6304,7 +6345,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn repository_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 31);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -6370,7 +6411,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 31);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -6633,7 +6674,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn account_and_login_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 31);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 32);
|
||||
let now = "2026-07-22T00:00:00Z".to_string();
|
||||
let account = AccountRecord {
|
||||
account_id: "acct-user-alice".to_string(),
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, source_digest: string, };
|
||||
@@ -1,4 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { VirtualPath } from "./VirtualPath";
|
||||
import type { WorkspaceConfigSchemaBundle } from "./WorkspaceConfigSchemaBundle";
|
||||
|
||||
export type ToolchainContract = { contract_version: number, decodal_version: string, schema_version: number, entrypoints: Array<VirtualPath>, import_policy_version: number, fingerprint: string, };
|
||||
export type ToolchainContract = { contract_version: number, decodal_version: string, schema_version: number, entrypoints: Array<VirtualPath>, import_policy_version: number, schema_bundle: WorkspaceConfigSchemaBundle, fingerprint: string, };
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ConfigSchemaContribution } from "./ConfigSchemaContribution";
|
||||
|
||||
export type WorkspaceConfigSchemaBundle = { contributions: Array<ConfigSchemaContribution>, source: string, fingerprint: string, };
|
||||
@@ -5,6 +5,8 @@ export type { ConfigDiagnosticLabel } from "./generated/types/ConfigDiagnosticLa
|
||||
export type { ConfigEntry } from "./generated/types/ConfigEntry.ts";
|
||||
export type { ConfigSpan } from "./generated/types/ConfigSpan.ts";
|
||||
export type { ConfigTreeChange } from "./generated/types/ConfigTreeChange.ts";
|
||||
export type { ConfigSchemaContribution } from "./generated/types/ConfigSchemaContribution.ts";
|
||||
export type { WorkspaceConfigSchemaBundle } from "./generated/types/WorkspaceConfigSchemaBundle.ts";
|
||||
export type { ConfigTreeSnapshot } from "./generated/types/ConfigTreeSnapshot.ts";
|
||||
export type { EvaluatedProjection } from "./generated/types/EvaluatedProjection.ts";
|
||||
export type { EvaluationResult } from "./generated/types/EvaluationResult.ts";
|
||||
|
||||
Reference in New Issue
Block a user