server: compose Workspace config schemas

This commit is contained in:
2026-08-14 05:38:21 +09:00
parent 7a8bd7717e
commit f8baa1edb7
9 changed files with 869 additions and 130 deletions
+8 -2
View File
@@ -1,6 +1,6 @@
use config_source::{ use config_source::{
ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment, ToolchainContract, ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult,
VirtualPath, SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
}; };
use serde_wasm_bindgen::{Serializer, from_value}; use serde_wasm_bindgen::{Serializer, from_value};
use std::cell::RefCell; use std::cell::RefCell;
@@ -10,6 +10,12 @@ thread_local! {
static SESSION: RefCell<Option<ConfigTreeSnapshot>> = const { RefCell::new(None) }; 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] #[wasm_bindgen]
pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> { pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> {
let snapshot: ConfigTreeSnapshot = decode(snapshot)?; let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
+413 -17
View File
@@ -9,10 +9,14 @@ use decodal_language_service::{CompletionResult, LanguageService};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; 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 DECODAL_VERSION: &str = "0.2.0";
pub const DEFAULT_SCHEMA_VERSION: u32 = 1; pub const DEFAULT_SCHEMA_VERSION: u32 = 1;
pub const DEFAULT_IMPORT_POLICY_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_ENTRY_COUNT: usize = 256;
pub const MAX_CHANGE_COUNT: usize = 256; pub const MAX_CHANGE_COUNT: usize = 256;
pub const MAX_ENTRY_BYTES: usize = 256 * 1024; 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ToolchainContract { pub struct ToolchainContract {
pub contract_version: u32, pub contract_version: u32,
@@ -328,14 +477,29 @@ pub struct ToolchainContract {
pub schema_version: u32, pub schema_version: u32,
pub entrypoints: Vec<VirtualPath>, pub entrypoints: Vec<VirtualPath>,
pub import_policy_version: u32, pub import_policy_version: u32,
pub schema_bundle: WorkspaceConfigSchemaBundle,
pub fingerprint: String, pub fingerprint: String,
} }
impl ToolchainContract { impl ToolchainContract {
pub fn new( 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, schema_version: u32,
mut entrypoints: Vec<VirtualPath>, mut entrypoints: Vec<VirtualPath>,
import_policy_version: u32, import_policy_version: u32,
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Self { ) -> Self {
entrypoints.sort(); entrypoints.sort();
entrypoints.dedup(); entrypoints.dedup();
@@ -345,6 +509,7 @@ impl ToolchainContract {
schema_version, schema_version,
entrypoints, entrypoints,
import_policy_version, import_policy_version,
schema_bundle,
fingerprint: String::new(), fingerprint: String::new(),
}; };
contract.fingerprint = digest_bytes( contract.fingerprint = digest_bytes(
@@ -354,12 +519,32 @@ impl ToolchainContract {
contract.schema_version, contract.schema_version,
&contract.entrypoints, &contract.entrypoints,
contract.import_policy_version, contract.import_policy_version,
&contract.schema_bundle.fingerprint,
)) ))
.expect("toolchain contract serializes") .expect("toolchain contract serializes")
.as_slice(), .as_slice(),
); );
contract 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
@@ -420,6 +605,13 @@ impl SnapshotEnvironment {
&self, &self,
contract: &ToolchainContract, contract: &ToolchainContract,
) -> Result<EvaluationResult, Vec<ConfigDiagnostic>> { ) -> 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 service = LanguageService::new(self);
let diagnostics = self let diagnostics = self
.snapshot .snapshot
@@ -440,8 +632,19 @@ impl SnapshotEnvironment {
if !diagnostics.is_empty() { if !diagnostics.is_empty() {
return Err(diagnostics); return Err(diagnostics);
} }
let mut projections = Vec::new(); let Some(entrypoint) = contract.entrypoints.first() else {
for entrypoint in &contract.entrypoints { 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 { let Some(entry) = self.snapshot.get(entrypoint) else {
return Err(vec![self.config_error( return Err(vec![self.config_error(
entrypoint.clone(), entrypoint.clone(),
@@ -456,29 +659,78 @@ impl SnapshotEnvironment {
"configured entrypoint is not Decodal source", "configured entrypoint is not Decodal source",
)]); )]);
} }
match service.evaluate(entrypoint.as_str(), entrypoint.as_str(), &entry.content) { let mut engine = decodal::Engine::new(SnapshotImportLoader {
Ok(data) => { 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 data_json = decodal_data_to_json(&data);
let projection_digest = digest_bytes( let projection_digest = digest_bytes(
serde_json::to_vec(&data_json) serde_json::to_vec(&data_json)
.expect("Decodal projection serializes") .expect("Decodal projection serializes")
.as_slice(), .as_slice(),
); );
projections.push(EvaluatedProjection { let projections = vec![EvaluatedProjection {
entrypoint: entrypoint.clone(), entrypoint: entrypoint.clone(),
data_json, data_json,
projection_digest, projection_digest,
}); }];
}
Err(diagnostic) => {
return Err(vec![project_diagnostic(
&self.snapshot,
entrypoint.clone(),
&diagnostic,
)]);
}
}
}
let projection_digest = digest_bytes( let projection_digest = digest_bytes(
serde_json::to_vec(&projections) serde_json::to_vec(&projections)
.expect("projection set serializes") .expect("projection set serializes")
@@ -692,6 +944,42 @@ pub fn import_completions(
candidates.into_iter().collect() 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( fn project_diagnostic(
snapshot: &ConfigTreeSnapshot, snapshot: &ConfigTreeSnapshot,
fallback_path: VirtualPath, fallback_path: VirtualPath,
@@ -807,6 +1095,12 @@ pub enum ConfigTreeError {
PathChangedMoreThanOnce(VirtualPath), PathChangedMoreThanOnce(VirtualPath),
#[error("duplicate virtual config path")] #[error("duplicate virtual config path")]
DuplicatePath, 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}")] #[error("virtual config limit exceeded: {0}")]
LimitExceeded(&'static str), LimitExceeded(&'static str),
} }
@@ -832,6 +1126,8 @@ mod tests {
export!(ConfigEntry); export!(ConfigEntry);
export!(ConfigTreeSnapshot); export!(ConfigTreeSnapshot);
export!(ConfigTreeChange); export!(ConfigTreeChange);
export!(ConfigSchemaContribution);
export!(WorkspaceConfigSchemaBundle);
export!(ToolchainContract); export!(ToolchainContract);
export!(ConfigSpan); export!(ConfigSpan);
export!(ConfigDiagnosticLabel); 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] #[test]
fn host_environment_evaluation_uses_only_snapshot_imports() { fn host_environment_evaluation_uses_only_snapshot_imports() {
let snapshot = ConfigTreeSnapshot::from_entries( let snapshot = ConfigTreeSnapshot::from_entries(
+290 -24
View File
@@ -1,8 +1,8 @@
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use config_source::{ use config_source::{
ConfigContentType, ConfigEntry, ConfigTreeChange, ConfigTreeSnapshot, DECODAL_VERSION, ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot,
DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult, SnapshotEnvironment, DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult,
ToolchainContract, VirtualPath, SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
}; };
use rusqlite::{OptionalExtension, TransactionBehavior, params}; use rusqlite::{OptionalExtension, TransactionBehavior, params};
use serde::{Deserialize, Serialize}; 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") VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).expect("main config entrypoint is a valid path")
} }
fn main_config_contract() -> ToolchainContract { pub trait WorkspaceConfigSchemaProvider: Send + Sync {
ToolchainContract::new( 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, DEFAULT_SCHEMA_VERSION,
vec![main_config_path()], vec![main_config_path()],
DEFAULT_IMPORT_POLICY_VERSION, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)] #[ts(export)]
pub struct WorkspaceConfigState { pub struct WorkspaceConfigState {
@@ -127,10 +163,11 @@ impl SqliteWorkspaceStore {
}) })
} }
pub fn evaluate_workspace_config_candidate( pub fn evaluate_workspace_config_candidate_with_schema(
&self, &self,
workspace_id: &str, workspace_id: &str,
request: &ConfigCommitRequest, request: &ConfigCommitRequest,
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> { ) -> Result<EvaluatedConfigCandidate> {
let current = self let current = self
.load_workspace_config(workspace_id)? .load_workspace_config(workspace_id)?
@@ -144,14 +181,39 @@ impl SqliteWorkspaceStore {
current.snapshot.revision 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 { if expected_contract.fingerprint != request.toolchain_fingerprint {
return Err(config_conflict(format!( return Err(config_conflict(format!(
"toolchain fingerprint mismatch; current fingerprint is {}", "toolchain fingerprint mismatch; current fingerprint is {}",
expected_contract.fingerprint 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( pub fn preview_workspace_config(
@@ -159,11 +221,11 @@ impl SqliteWorkspaceStore {
workspace_id: &str, workspace_id: &str,
request: &ConfigPreviewRequest, request: &ConfigPreviewRequest,
) -> Result<EvaluatedConfigCandidate> { ) -> Result<EvaluatedConfigCandidate> {
validate_entrypoint_request(&request.entrypoints)?; self.preview_workspace_config_with_schema(
let current = self workspace_id,
.load_workspace_config(workspace_id)? request,
.ok_or_else(config_not_materialized)?; WorkspaceConfigSchemaBundle::empty(),
evaluate_candidate(current, &request.changes) )
} }
pub fn commit_evaluated_workspace_config( pub fn commit_evaluated_workspace_config(
@@ -197,9 +259,9 @@ impl SqliteWorkspaceStore {
tx.execute( tx.execute(
r#"INSERT INTO workspace_config_trees ( r#"INSERT INTO workspace_config_trees (
workspace_id, revision, tree_digest, schema_version, entrypoints_json, workspace_id, revision, tree_digest, schema_version, entrypoints_json,
decodal_version, import_policy_version, toolchain_fingerprint, decodal_version, import_policy_version, schema_bundle_json,
projection_digest, updated_at toolchain_fingerprint, projection_digest, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(workspace_id) DO UPDATE SET ON CONFLICT(workspace_id) DO UPDATE SET
revision = excluded.revision, revision = excluded.revision,
tree_digest = excluded.tree_digest, tree_digest = excluded.tree_digest,
@@ -207,6 +269,7 @@ impl SqliteWorkspaceStore {
entrypoints_json = excluded.entrypoints_json, entrypoints_json = excluded.entrypoints_json,
decodal_version = excluded.decodal_version, decodal_version = excluded.decodal_version,
import_policy_version = excluded.import_policy_version, import_policy_version = excluded.import_policy_version,
schema_bundle_json = excluded.schema_bundle_json,
toolchain_fingerprint = excluded.toolchain_fingerprint, toolchain_fingerprint = excluded.toolchain_fingerprint,
projection_digest = excluded.projection_digest, projection_digest = excluded.projection_digest,
updated_at = excluded.updated_at"#, updated_at = excluded.updated_at"#,
@@ -219,6 +282,8 @@ impl SqliteWorkspaceStore {
.map_err(|error| Error::Store(error.to_string()))?, .map_err(|error| Error::Store(error.to_string()))?,
candidate.contract.decodal_version, candidate.contract.decodal_version,
candidate.contract.import_policy_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.contract.fingerprint,
candidate.evaluation.projection_digest, candidate.evaluation.projection_digest,
now, now,
@@ -247,13 +312,15 @@ impl SqliteWorkspaceStore {
tx.execute( tx.execute(
r#"INSERT INTO workspace_config_tree_revisions ( r#"INSERT INTO workspace_config_tree_revisions (
workspace_id, revision, tree_digest, toolchain_fingerprint, workspace_id, revision, tree_digest, toolchain_fingerprint,
projection_digest, manifest_json, created_at schema_bundle_json, projection_digest, manifest_json, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#,
params![ params![
workspace_id, workspace_id,
next_revision as i64, next_revision as i64,
snapshot.digest, snapshot.digest,
candidate.contract.fingerprint, candidate.contract.fingerprint,
serde_json::to_string(&candidate.contract.schema_bundle)
.map_err(|error| Error::Store(error.to_string()))?,
candidate.evaluation.projection_digest, candidate.evaluation.projection_digest,
manifest_json, manifest_json,
now, now,
@@ -281,11 +348,12 @@ impl SqliteWorkspaceStore {
fn evaluate_candidate( fn evaluate_candidate(
current: WorkspaceConfigState, current: WorkspaceConfigState,
changes: &[ConfigTreeChange], changes: &[ConfigTreeChange],
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> { ) -> Result<EvaluatedConfigCandidate> {
reject_main_entrypoint_mutation(changes)?; reject_main_entrypoint_mutation(changes)?;
let snapshot = current.snapshot.apply(changes).map_err(config_error)?; let snapshot = current.snapshot.apply(changes).map_err(config_error)?;
ensure_main_entrypoint(&snapshot)?; ensure_main_entrypoint(&snapshot)?;
let contract = main_config_contract(); let contract = main_config_contract_with_schema(schema_bundle);
let evaluation = SnapshotEnvironment::new(snapshot.clone()) let evaluation = SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract) .evaluate_contract(&contract)
.map_err(|diagnostics| { .map_err(|diagnostics| {
@@ -307,10 +375,19 @@ pub(crate) fn load_state(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
workspace_id: &str, workspace_id: &str,
) -> Result<Option<WorkspaceConfigState>> { ) -> Result<Option<WorkspaceConfigState>> {
let header = conn let has_schema_bundle: bool = conn.query_row(
.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, 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"#, FROM workspace_config_trees WHERE workspace_id = ?1"#,
[workspace_id], [workspace_id],
|row| { |row| {
@@ -321,12 +398,36 @@ pub(crate) fn load_state(
row.get::<_, String>(3)?, row.get::<_, String>(3)?,
row.get::<_, String>(4)?, row.get::<_, String>(4)?,
row.get::<_, u32>(5)?, 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>(6)?,
row.get::<_, String>(7)?, row.get::<_, String>(7)?,
)) ))
}, },
) )
.optional()?; .optional()?
};
let Some(( let Some((
revision, revision,
stored_digest, stored_digest,
@@ -334,6 +435,7 @@ pub(crate) fn load_state(
entrypoints_json, entrypoints_json,
decodal_version, decodal_version,
import_policy_version, import_policy_version,
schema_bundle_json,
fingerprint, fingerprint,
projection_digest, projection_digest,
)) = header )) = header
@@ -376,7 +478,17 @@ pub(crate) fn load_state(
} }
let entrypoints: Vec<VirtualPath> = serde_json::from_str(&entrypoints_json) let entrypoints: Vec<VirtualPath> = serde_json::from_str(&entrypoints_json)
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?; .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 { if decodal_version != DECODAL_VERSION || contract.fingerprint != fingerprint {
return Err(Error::RegistryInconsistency(format!( return Err(Error::RegistryInconsistency(format!(
"virtual config toolchain metadata mismatch for Workspace {workspace_id}" "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()))?; .map_err(|error| Error::Store(error.to_string()))?;
let manifest_json = serde_json::to_string(&state.snapshot.entries) let manifest_json = serde_json::to_string(&state.snapshot.entries)
.map_err(|error| Error::Store(error.to_string()))?; .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( tx.execute(
"INSERT INTO workspace_config_trees ( "INSERT INTO workspace_config_trees (
workspace_id, revision, tree_digest, schema_version, entrypoints_json, workspace_id, revision, tree_digest, schema_version, entrypoints_json,
@@ -454,6 +609,7 @@ pub(crate) fn insert_materialized_state(
materialized_at, materialized_at,
], ],
)?; )?;
}
for entry in state.snapshot.entries.values() { for entry in state.snapshot.entries.values() {
tx.execute( tx.execute(
"INSERT INTO workspace_config_entries ( "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( tx.execute(
"INSERT INTO workspace_config_tree_revisions ( "INSERT INTO workspace_config_tree_revisions (
workspace_id, revision, tree_digest, toolchain_fingerprint, workspace_id, revision, tree_digest, toolchain_fingerprint,
@@ -483,6 +657,7 @@ pub(crate) fn insert_materialized_state(
materialized_at, materialized_at,
], ],
)?; )?;
}
Ok(()) 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] #[tokio::test]
async fn workspace_materializes_main_entrypoint() { async fn workspace_materializes_main_entrypoint() {
let store = open_store().await; let store = open_store().await;
@@ -806,6 +1071,7 @@ mod tests {
[], [],
) )
.unwrap(); .unwrap();
crate::store::persist_workspace_config_schema_bundles(&conn).unwrap();
crate::store::materialize_main_config_entrypoint(&conn).unwrap(); crate::store::materialize_main_config_entrypoint(&conn).unwrap();
let state = load_state(&conn, "legacy").unwrap().unwrap(); let state = load_state(&conn, "legacy").unwrap().unwrap();
assert!( assert!(
+23 -3
View File
@@ -255,6 +255,7 @@ pub struct WorkspaceApi {
pub(crate) config: ServerConfig, pub(crate) config: ServerConfig,
pub(crate) store: Arc<dyn ControlPlaneStore>, pub(crate) store: Arc<dyn ControlPlaneStore>,
config_store: Arc<crate::SqliteWorkspaceStore>, config_store: Arc<crate::SqliteWorkspaceStore>,
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
authority: SqliteWorkspaceAuthority, authority: SqliteWorkspaceAuthority,
runtime: Arc<RuntimeRegistry>, runtime: Arc<RuntimeRegistry>,
companion: Arc<CompanionConsole>, companion: Arc<CompanionConsole>,
@@ -643,6 +644,14 @@ impl crate::worker_source::VerifiedWorkerRemoveExecutor for WorkspaceWorkerRemov
} }
impl WorkspaceApi { 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> { pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
let resource_broker = BackendResourceBroker::default(); let resource_broker = BackendResourceBroker::default();
let worker_remove_dispatcher = Arc::new( let worker_remove_dispatcher = Arc::new(
@@ -749,6 +758,7 @@ impl WorkspaceApi {
)?); )?);
let api = Self { let api = Self {
config_store, config_store,
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry::default(),
authority: SqliteWorkspaceAuthority::new( authority: SqliteWorkspaceAuthority::new(
config.database_path.clone(), config.database_path.clone(),
config.workspace_id.clone(), config.workspace_id.clone(),
@@ -2528,8 +2538,11 @@ async fn scoped_preview_workspace_config_tree(
) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> { ) -> ApiResult<Json<crate::config_source::EvaluatedConfigCandidate>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json( Ok(Json(
api.config_store api.config_store.preview_workspace_config_with_schema(
.preview_workspace_config(&path.workspace_id, &request)?, &path.workspace_id,
&request,
api.config_schema_registry.compose()?,
)?,
)) ))
} }
@@ -2539,9 +2552,16 @@ async fn scoped_commit_workspace_config_tree(
Json(request): Json<ConfigCommitRequest>, Json(request): Json<ConfigCommitRequest>,
) -> ApiResult<(StatusCode, Json<WorkspaceConfigTreeResponse>)> { ) -> ApiResult<(StatusCode, Json<WorkspaceConfigTreeResponse>)> {
validate_workspace_scope(&api, &path.workspace_id)?; 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 let state = api
.config_store .config_store
.evaluate_and_commit_workspace_config(&path.workspace_id, &request)?; .commit_evaluated_workspace_config(&path.workspace_id, &candidate)?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(WorkspaceConfigTreeResponse { Json(WorkspaceConfigTreeResponse {
+59 -18
View File
@@ -176,6 +176,11 @@ const MIGRATIONS: &[Migration] = &[
name: "materialize required main.dcdl Workspace config entrypoint", name: "materialize required main.dcdl Workspace config entrypoint",
apply: materialize_main_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 { struct Migration {
@@ -4598,6 +4603,42 @@ fn create_workspace_config_source_authority(conn: &Connection) -> Result<()> {
Ok(()) 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<()> { fn create_worker_mutation_source_proof_replay_guard(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
r#" r#"
@@ -5271,7 +5312,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
apply_migrations(&conn).unwrap(); 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()); 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(); 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_sources").unwrap());
assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap());
assert!(!table_exists(&conn, "flow_instances").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap());
@@ -5371,7 +5412,7 @@ INSERT INTO worker_workdir_attachment_reservations (
apply_migrations(&conn).unwrap(); 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 let repositories_sql: String = conn
.query_row( .query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", "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 db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap(); let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 31); assert_eq!(store.schema_version().await.unwrap(), 32);
assert!( assert!(
!store !store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) .with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -5568,7 +5609,7 @@ INSERT INTO workdir_registry (
store.upsert_workspace(&record).await.unwrap(); store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).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!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@@ -5661,8 +5702,8 @@ INSERT INTO workdir_registry (
owner_account_id: None, owner_account_id: None,
display_name: "Workspace A".to_string(), display_name: "Workspace A".to_string(),
state: "active".to_string(), state: "active".to_string(),
created_at: "2026-07-31T00:00:00Z".to_string(), created_at: "2026-07-32T00:00:00Z".to_string(),
updated_at: "2026-07-31T00:00:00Z".to_string(), updated_at: "2026-07-32T00:00:00Z".to_string(),
}) })
.await .await
.unwrap(); .unwrap();
@@ -5673,7 +5714,7 @@ INSERT INTO workdir_registry (
assignment_id: "assignment-1".to_string(), assignment_id: "assignment-1".to_string(),
worker: RuntimeWorkerRef::new("runtime-1", "worker-1"), worker: RuntimeWorkerRef::new("runtime-1", "worker-1"),
assigned_by: "user-1".to_string(), 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 let created = store
.set_current_ticket_worker_assignment(&first, None, "event-1", "operation-1", false) .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(), assignment_id: "assignment-2".to_string(),
worker: RuntimeWorkerRef::new("runtime-2", "worker-2"), worker: RuntimeWorkerRef::new("runtime-2", "worker-2"),
assigned_by: "user-2".to_string(), 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() ..first.clone()
}; };
let replaced = store let replaced = store
@@ -5779,7 +5820,7 @@ INSERT INTO workdir_registry (
"unassign-operation-stale", "unassign-operation-stale",
"event-stale", "event-stale",
"user-1", "user-1",
"2026-07-31T00:00:03Z", "2026-07-32T00:00:03Z",
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(stale, Error::TicketAssignmentConflict(_))); assert!(matches!(stale, Error::TicketAssignmentConflict(_)));
@@ -5792,7 +5833,7 @@ INSERT INTO workdir_registry (
"unassign-operation-2", "unassign-operation-2",
"event-3", "event-3",
"user-2", "user-2",
"2026-07-31T00:00:03Z", "2026-07-32T00:00:03Z",
) )
.unwrap(); .unwrap();
assert_eq!(cleared, Some(second.clone())); assert_eq!(cleared, Some(second.clone()));
@@ -5804,7 +5845,7 @@ INSERT INTO workdir_registry (
"unassign-operation-2", "unassign-operation-2",
"ignored-clear-event", "ignored-clear-event",
"user-2", "user-2",
"2026-07-31T00:00:04Z", "2026-07-32T00:00:04Z",
) )
.unwrap(); .unwrap();
assert_eq!(retried_clear, Some(second)); assert_eq!(retried_clear, Some(second));
@@ -5816,7 +5857,7 @@ INSERT INTO workdir_registry (
"runtime-3", "runtime-3",
None, None,
"sha256:reserved", "sha256:reserved",
"2026-07-31T00:00:05Z", "2026-07-32T00:00:05Z",
) )
.unwrap(); .unwrap();
drop(store); drop(store);
@@ -5843,7 +5884,7 @@ INSERT INTO workdir_registry (
assignment_id: "assignment-3".to_string(), assignment_id: "assignment-3".to_string(),
worker: RuntimeWorkerRef::new("runtime-3", "worker-3"), worker: RuntimeWorkerRef::new("runtime-3", "worker-3"),
assigned_by: "runtime".to_string(), 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 let completed_reservation = store
.set_current_ticket_worker_assignment( .set_current_ticket_worker_assignment(
@@ -6115,7 +6156,7 @@ INSERT INTO workdir_registry (
.unwrap(); .unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).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 store
.with_conn(|conn| { .with_conn(|conn| {
@@ -6304,7 +6345,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn repository_records_round_trip() { async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); 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 { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@@ -6370,7 +6411,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() { async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); 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 { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@@ -6633,7 +6674,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn account_and_login_records_round_trip() { async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); 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 now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord { let account = AccountRecord {
account_id: "acct-user-alice".to_string(), account_id: "acct-user-alice".to_string(),
@@ -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. // 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 { 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, };
@@ -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 { ConfigEntry } from "./generated/types/ConfigEntry.ts";
export type { ConfigSpan } from "./generated/types/ConfigSpan.ts"; export type { ConfigSpan } from "./generated/types/ConfigSpan.ts";
export type { ConfigTreeChange } from "./generated/types/ConfigTreeChange.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 { ConfigTreeSnapshot } from "./generated/types/ConfigTreeSnapshot.ts";
export type { EvaluatedProjection } from "./generated/types/EvaluatedProjection.ts"; export type { EvaluatedProjection } from "./generated/types/EvaluatedProjection.ts";
export type { EvaluationResult } from "./generated/types/EvaluationResult.ts"; export type { EvaluationResult } from "./generated/types/EvaluationResult.ts";