From f8baa1edb7540364bb909667ad1bf5c19561c999 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 14 Aug 2026 05:38:21 +0900 Subject: [PATCH] server: compose Workspace config schemas --- crates/config-source-wasm/src/lib.rs | 10 +- crates/config-source/src/lib.rs | 474 ++++++++++++++++-- crates/workspace-server/src/config_source.rs | 400 ++++++++++++--- crates/workspace-server/src/server.rs | 26 +- crates/workspace-server/src/store.rs | 77 ++- .../types/ConfigSchemaContribution.ts | 3 + .../generated/types/ToolchainContract.ts | 3 +- .../types/WorkspaceConfigSchemaBundle.ts | 4 + .../src/lib/workspace/config-source/types.ts | 2 + 9 files changed, 869 insertions(+), 130 deletions(-) create mode 100644 web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts create mode 100644 web/workspace/src/lib/workspace/config-source/generated/types/WorkspaceConfigSchemaBundle.ts diff --git a/crates/config-source-wasm/src/lib.rs b/crates/config-source-wasm/src/lib.rs index 5b992f64..f15c6f7b 100644 --- a/crates/config-source-wasm/src/lib.rs +++ b/crates/config-source-wasm/src/lib.rs @@ -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> = const { RefCell::new(None) }; } +#[wasm_bindgen] +pub fn compose_schema_bundle(contributions: JsValue) -> Result { + let contributions: Vec = 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)?; diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index 49c0a9f5..3e5ba964 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -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, + namespace: impl Into, + version: impl Into, + source: impl Into, + ) -> Result { + 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, + pub source: String, + pub fingerprint: String, +} + +impl WorkspaceConfigSchemaBundle { + pub fn compose( + contributions: impl IntoIterator, + ) -> Result { + let mut contributions = contributions.into_iter().collect::>(); + 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::>() + .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::>(), + 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, pub import_policy_version: u32, + pub schema_bundle: WorkspaceConfigSchemaBundle, pub fingerprint: String, } impl ToolchainContract { pub fn new( + schema_version: u32, + entrypoints: Vec, + 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, 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> { + 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,45 +632,105 @@ impl SnapshotEnvironment { if !diagnostics.is_empty() { return Err(diagnostics); } - let mut projections = Vec::new(); - for entrypoint in &contract.entrypoints { - let Some(entry) = self.snapshot.get(entrypoint) else { - return Err(vec![self.config_error( - entrypoint.clone(), - "entrypoint_missing", - "configured entrypoint is missing", - )]); - }; - if entry.content_type != ConfigContentType::Decodal { - return Err(vec![self.config_error( - entrypoint.clone(), - "entrypoint_not_decodal", - "configured entrypoint is not Decodal source", - )]); - } - match service.evaluate(entrypoint.as_str(), entrypoint.as_str(), &entry.content) { - Ok(data) => { - 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 { - entrypoint: entrypoint.clone(), - data_json, - projection_digest, - }); - } - Err(diagnostic) => { - return Err(vec![project_diagnostic( - &self.snapshot, - entrypoint.clone(), - &diagnostic, - )]); - } - } + 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(), + "entrypoint_missing", + "configured entrypoint is missing", + )]); + }; + if entry.content_type != ConfigContentType::Decodal { + return Err(vec![self.config_error( + entrypoint.clone(), + "entrypoint_not_decodal", + "configured entrypoint is not Decodal source", + )]); + } + 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(), + ); + let projections = vec![EvaluatedProjection { + entrypoint: entrypoint.clone(), + data_json, + projection_digest, + }]; 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, + 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( diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 1a210df4..bd2be398 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -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; +} + +#[derive(Clone, Default)] +pub struct WorkspaceConfigSchemaRegistry { + providers: Vec>, +} + +impl WorkspaceConfigSchemaRegistry { + pub fn with_provider( + mut self, + provider: std::sync::Arc, + ) -> Self { + self.providers.push(provider); + self + } + + pub fn compose(&self) -> Result { + WorkspaceConfigSchemaBundle::compose( + self.providers + .iter() + .map(|provider| provider.contribution()) + .collect::>>()?, + ) + .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 { 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 { + 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 { + 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 { - 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 { 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> { - 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 = 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,35 +537,79 @@ 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()))?; - tx.execute( - "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) - 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, - 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, - state.contract.fingerprint, - state.projection_digest, - materialized_at, - ], + 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, + decodal_version, import_policy_version, toolchain_fingerprint, + projection_digest, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + 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, + 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, + state.contract.fingerprint, + state.projection_digest, + materialized_at, + ], + )?; + } for entry in state.snapshot.entries.values() { tx.execute( "INSERT INTO workspace_config_entries ( @@ -468,21 +624,40 @@ pub(crate) fn insert_materialized_state( ], )?; } - tx.execute( - "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)", - rusqlite::params![ - workspace_id, - state.snapshot.revision, - state.snapshot.digest, - state.contract.fingerprint, - state.projection_digest, - manifest_json, - materialized_at, - ], - )?; + 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, + projection_digest, manifest_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + workspace_id, + state.snapshot.revision, + state.snapshot.digest, + state.contract.fingerprint, + state.projection_digest, + manifest_json, + 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::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!( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 5a01c6e3..b2354b8b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -255,6 +255,7 @@ pub struct WorkspaceApi { pub(crate) config: ServerConfig, pub(crate) store: Arc, config_store: Arc, + config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry, authority: SqliteWorkspaceAuthority, runtime: Arc, companion: Arc, @@ -643,6 +644,14 @@ impl crate::worker_source::VerifiedWorkerRemoveExecutor for WorkspaceWorkerRemov } impl WorkspaceApi { + pub fn with_config_schema_provider( + mut self, + provider: Arc, + ) -> Self { + self.config_schema_registry = self.config_schema_registry.with_provider(provider); + self + } + pub async fn new(config: ServerConfig, store: Arc) -> Result { 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> { 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, ) -> ApiResult<(StatusCode, Json)> { 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 { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index c86e8b0c..82df8bc3 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -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(), diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts new file mode 100644 index 00000000..4e89881b --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts @@ -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, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ToolchainContract.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ToolchainContract.ts index 66f3d3c6..152cd58f 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/types/ToolchainContract.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ToolchainContract.ts @@ -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, import_policy_version: number, fingerprint: string, }; +export type ToolchainContract = { contract_version: number, decodal_version: string, schema_version: number, entrypoints: Array, import_policy_version: number, schema_bundle: WorkspaceConfigSchemaBundle, fingerprint: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/WorkspaceConfigSchemaBundle.ts b/web/workspace/src/lib/workspace/config-source/generated/types/WorkspaceConfigSchemaBundle.ts new file mode 100644 index 00000000..48b2e3d8 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/generated/types/WorkspaceConfigSchemaBundle.ts @@ -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, source: string, fingerprint: string, }; diff --git a/web/workspace/src/lib/workspace/config-source/types.ts b/web/workspace/src/lib/workspace/config-source/types.ts index f8fa6ac8..6358f5e5 100644 --- a/web/workspace/src/lib/workspace/config-source/types.ts +++ b/web/workspace/src/lib/workspace/config-source/types.ts @@ -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";