config: migrate workspace evaluation to Decodal 0.4
This commit is contained in:
Generated
+6
-6
@@ -986,24 +986,24 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal"
|
name = "decodal"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2b6e47d6bc66cd3cd42c8df8ff77a994a7743e889045c6b762a6dbc360ad8494"
|
checksum = "30e2a1ff0bf0d4160b998401a82aef64d26395e5b4798c55fbca6880a52f8e64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal-language-service"
|
name = "decodal-language-service"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f25e462dce7c86743bd229ba91b831daf7928d524de9cef4ef861257ca156aa8"
|
checksum = "577f8cdf109dc318c6bef32c95194f0b164ccea234ab0f70a02aeb3296813b16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"decodal",
|
"decodal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decodal-language-tools"
|
name = "decodal-language-tools"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08d8e3eb978cb1c2259df838ba2a8102bc45553d7b415216c80fc5b6a3f378c6"
|
checksum = "2a18ca80b27386c5dbb160c74e88e6c1bfe33fd80f3cd5108bebe800b8aa698c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"decodal",
|
"decodal",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
+3
-3
@@ -98,9 +98,9 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
decodal = "0.2.0"
|
decodal = "0.4.0"
|
||||||
decodal-language-service = "0.2.0"
|
decodal-language-service = "0.4.0"
|
||||||
decodal-language-tools = "0.2.0"
|
decodal-language-tools = "0.4.0"
|
||||||
fs4 = "0.13"
|
fs4 = "0.13"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 2;
|
pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 2;
|
||||||
pub const DECODAL_VERSION: &str = "0.2.0";
|
pub const DECODAL_VERSION: &str = "0.4.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_GLOBAL: &str = "WorkspaceConfigSchema";
|
||||||
pub const WORKSPACE_CONFIG_SCHEMA_SOURCE: &str = "workspace-config-schema.dcdl";
|
pub const WORKSPACE_CONFIG_SCHEMA_SOURCE: &str = "workspace-config-schema.dcdl";
|
||||||
pub const WORKSPACE_CONFIG_EVALUATION_SOURCE: &str =
|
pub const WORKSPACE_CONFIG_EVALUATION_SOURCE: &str =
|
||||||
"WorkspaceConfigSchema & import \"__MAIN_ENTRYPOINT__\"";
|
"import \"__MAIN_ENTRYPOINT__\" as WorkspaceConfigSchema";
|
||||||
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;
|
||||||
@@ -686,8 +686,11 @@ impl SnapshotEnvironment {
|
|||||||
)]
|
)]
|
||||||
})?;
|
})?;
|
||||||
engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema);
|
engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema);
|
||||||
let evaluation_source =
|
let evaluation_source = if contract.schema_bundle.contributions.is_empty() {
|
||||||
WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str());
|
format!("import \"{}\"", entrypoint.as_str())
|
||||||
|
} else {
|
||||||
|
WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str())
|
||||||
|
};
|
||||||
let evaluation_module = engine
|
let evaluation_module = engine
|
||||||
.add_root_source(
|
.add_root_source(
|
||||||
"workspace-config-evaluation.dcdl",
|
"workspace-config-evaluation.dcdl",
|
||||||
@@ -1270,10 +1273,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_schema_is_applied_with_normal_decodal_composition() {
|
fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() {
|
||||||
let snapshot =
|
let snapshot =
|
||||||
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; custom = 42; }")])
|
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; }")]).unwrap();
|
||||||
.unwrap();
|
|
||||||
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
"builtin:web",
|
"builtin:web",
|
||||||
"web",
|
"web",
|
||||||
@@ -1287,7 +1289,118 @@ mod tests {
|
|||||||
.evaluate_contract(&contract)
|
.evaluate_contract(&contract)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.projections[0].data_json["web"]["enabled"], false);
|
assert_eq!(result.projections[0].data_json["web"]["enabled"], false);
|
||||||
assert_eq!(result.projections[0].data_json["custom"], 42);
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_schema_rejects_unknown_root_and_nested_fields() {
|
||||||
|
let schema = || {
|
||||||
|
WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:web",
|
||||||
|
"web",
|
||||||
|
"1",
|
||||||
|
"{ web = { enabled = Bool default false; }; }",
|
||||||
|
)
|
||||||
|
.unwrap()])
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
for (source, unknown_field) in [
|
||||||
|
("{ web = {}; custom = 42; }", "custom"),
|
||||||
|
("{ web = { typo = true; }; }", "typo"),
|
||||||
|
] {
|
||||||
|
let snapshot =
|
||||||
|
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).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].path, path("main.dcdl"));
|
||||||
|
assert_eq!(diagnostics[0].kind, "constraintviolation");
|
||||||
|
assert!(diagnostics[0].message.contains(unknown_field));
|
||||||
|
assert!(diagnostics[0].span.end_byte > diagnostics[0].span.start_byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_schema_supports_typed_associative_collections() {
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||||
|
1,
|
||||||
|
[entry(
|
||||||
|
"main.dcdl",
|
||||||
|
"{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }",
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:features",
|
||||||
|
"features",
|
||||||
|
"1",
|
||||||
|
"{ features = {...{ enabled = Bool; }}; }",
|
||||||
|
)
|
||||||
|
.unwrap()])
|
||||||
|
.unwrap();
|
||||||
|
let result = SnapshotEnvironment::new(snapshot)
|
||||||
|
.evaluate_contract(&ToolchainContract::with_schema_bundle(
|
||||||
|
1,
|
||||||
|
vec![path("main.dcdl")],
|
||||||
|
1,
|
||||||
|
schema,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.projections[0].data_json["features"]["web"]["enabled"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.projections[0].data_json["features"]["tickets"]["enabled"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_schema_preserves_fields_only_where_rest_is_explicit() {
|
||||||
|
let snapshot = ConfigTreeSnapshot::from_entries(
|
||||||
|
1,
|
||||||
|
[entry(
|
||||||
|
"main.dcdl",
|
||||||
|
"{ web = { enabled = true; extension_value = 42; }; }",
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:web",
|
||||||
|
"web",
|
||||||
|
"1",
|
||||||
|
"{ web = { enabled = Bool; ...Unknown }; }",
|
||||||
|
)
|
||||||
|
.unwrap()])
|
||||||
|
.unwrap();
|
||||||
|
let result = SnapshotEnvironment::new(snapshot)
|
||||||
|
.evaluate_contract(&ToolchainContract::with_schema_bundle(
|
||||||
|
1,
|
||||||
|
vec![path("main.dcdl")],
|
||||||
|
1,
|
||||||
|
schema,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.projections[0].data_json["web"]["extension_value"],
|
||||||
|
42
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unresolved_unknown_cannot_be_materialized() {
|
||||||
|
let snapshot =
|
||||||
|
ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap();
|
||||||
|
let diagnostics = SnapshotEnvironment::new(snapshot)
|
||||||
|
.evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(diagnostics[0].path, path("main.dcdl"));
|
||||||
|
assert!(!diagnostics[0].message.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use config_source::{
|
use config_source::{
|
||||||
ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot,
|
ConfigContentType, ConfigDiagnostic, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange,
|
||||||
DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult,
|
ConfigTreeSnapshot, DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION,
|
||||||
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
EvaluationResult, SnapshotEnvironment, ToolchainContract, VirtualPath,
|
||||||
|
WorkspaceConfigSchemaBundle,
|
||||||
};
|
};
|
||||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -11,6 +12,24 @@ use crate::{Error, Result, SqliteWorkspaceStore};
|
|||||||
|
|
||||||
pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl";
|
pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl";
|
||||||
pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n";
|
pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n";
|
||||||
|
const MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS: usize = 20;
|
||||||
|
|
||||||
|
fn toolchain_upgrade_diagnostics(mut diagnostics: Vec<ConfigDiagnostic>) -> Error {
|
||||||
|
let omitted = diagnostics
|
||||||
|
.len()
|
||||||
|
.saturating_sub(MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS);
|
||||||
|
diagnostics.truncate(MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS);
|
||||||
|
let rendered = serde_json::to_string(&diagnostics)
|
||||||
|
.unwrap_or_else(|_| "[diagnostics could not be serialized]".to_string());
|
||||||
|
let suffix = if omitted == 0 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("; {omitted} additional diagnostic(s) omitted")
|
||||||
|
};
|
||||||
|
Error::InvalidInput(format!(
|
||||||
|
"workspace configuration is invalid under Decodal {DECODAL_VERSION}: {rendered}{suffix}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn main_config_path() -> VirtualPath {
|
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")
|
||||||
@@ -125,7 +144,7 @@ impl SqliteWorkspaceStore {
|
|||||||
schema_bundle: WorkspaceConfigSchemaBundle,
|
schema_bundle: WorkspaceConfigSchemaBundle,
|
||||||
) -> Result<WorkspaceConfigState> {
|
) -> Result<WorkspaceConfigState> {
|
||||||
let desired_schema = schema_bundle.clone();
|
let desired_schema = schema_bundle.clone();
|
||||||
let state = self.with_conn_mut(|conn| {
|
let (state, requires_toolchain_refresh) = self.with_conn_mut(|conn| {
|
||||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let workspace_exists: bool = tx.query_row(
|
let workspace_exists: bool = tx.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|
||||||
@@ -135,6 +154,16 @@ impl SqliteWorkspaceStore {
|
|||||||
if !workspace_exists {
|
if !workspace_exists {
|
||||||
return Err(Error::WorkspaceIdMismatch);
|
return Err(Error::WorkspaceIdMismatch);
|
||||||
}
|
}
|
||||||
|
let stored_decodal_version = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1",
|
||||||
|
[workspace_id],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let requires_toolchain_refresh = stored_decodal_version
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|version| version != DECODAL_VERSION);
|
||||||
let state = match load_state(&tx, workspace_id)? {
|
let state = match load_state(&tx, workspace_id)? {
|
||||||
Some(state) => state,
|
Some(state) => state,
|
||||||
None => {
|
None => {
|
||||||
@@ -144,10 +173,11 @@ impl SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(state)
|
Ok((state, requires_toolchain_refresh))
|
||||||
})?;
|
})?;
|
||||||
if state.contract.schema_bundle.contributions.is_empty()
|
if requires_toolchain_refresh
|
||||||
&& !desired_schema.contributions.is_empty()
|
|| (state.contract.schema_bundle.contributions.is_empty()
|
||||||
|
&& !desired_schema.contributions.is_empty())
|
||||||
{
|
{
|
||||||
let candidate = evaluate_candidate(state, &[], desired_schema)?;
|
let candidate = evaluate_candidate(state, &[], desired_schema)?;
|
||||||
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
|
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
|
||||||
@@ -507,22 +537,42 @@ 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 schema_bundle = match schema_bundle_json {
|
let stored_schema_bundle: WorkspaceConfigSchemaBundle = match schema_bundle_json {
|
||||||
Some(schema_bundle_json) => serde_json::from_str(&schema_bundle_json)
|
Some(schema_bundle_json) => serde_json::from_str(&schema_bundle_json)
|
||||||
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?,
|
.map_err(|error| Error::RegistryInconsistency(error.to_string()))?,
|
||||||
None => WorkspaceConfigSchemaBundle::empty(),
|
None => WorkspaceConfigSchemaBundle::empty(),
|
||||||
};
|
};
|
||||||
|
let requires_toolchain_refresh = decodal_version != DECODAL_VERSION;
|
||||||
|
if requires_toolchain_refresh && !matches!(decodal_version.as_str(), "0.2.0" | "0.3.0") {
|
||||||
|
return Err(Error::RegistryInconsistency(format!(
|
||||||
|
"unsupported virtual config Decodal version {decodal_version} for Workspace {workspace_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let schema_bundle = if requires_toolchain_refresh {
|
||||||
|
WorkspaceConfigSchemaBundle::compose(stored_schema_bundle.contributions)
|
||||||
|
.map_err(config_error)?
|
||||||
|
} else {
|
||||||
|
stored_schema_bundle
|
||||||
|
};
|
||||||
let contract = ToolchainContract::with_schema_bundle(
|
let contract = ToolchainContract::with_schema_bundle(
|
||||||
schema_version,
|
schema_version,
|
||||||
entrypoints,
|
entrypoints,
|
||||||
import_policy_version,
|
import_policy_version,
|
||||||
schema_bundle,
|
schema_bundle,
|
||||||
);
|
);
|
||||||
if decodal_version != DECODAL_VERSION || contract.fingerprint != fingerprint {
|
if !requires_toolchain_refresh && 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}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let projection_digest = if requires_toolchain_refresh {
|
||||||
|
SnapshotEnvironment::new(snapshot.clone())
|
||||||
|
.evaluate_contract(&contract)
|
||||||
|
.map_err(toolchain_upgrade_diagnostics)?
|
||||||
|
.projection_digest
|
||||||
|
} else {
|
||||||
|
projection_digest
|
||||||
|
};
|
||||||
Ok(Some(WorkspaceConfigState {
|
Ok(Some(WorkspaceConfigState {
|
||||||
snapshot,
|
snapshot,
|
||||||
contract,
|
contract,
|
||||||
@@ -976,6 +1026,170 @@ mod tests {
|
|||||||
assert_eq!(reloaded.projection_digest, state.projection_digest);
|
assert_eq!(reloaded.projection_digest, state.projection_digest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn toolchain_upgrade_re_evaluates_current_tree_and_preserves_prior_revision() {
|
||||||
|
let store = open_store().await;
|
||||||
|
let schema_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:test",
|
||||||
|
"test",
|
||||||
|
"1",
|
||||||
|
r#"{ test = { value = String default "initial"; }; }"#,
|
||||||
|
)
|
||||||
|
.unwrap()])
|
||||||
|
.unwrap();
|
||||||
|
let current = store
|
||||||
|
.ensure_workspace_config_materialized_with_schema(
|
||||||
|
"w-config",
|
||||||
|
"2026-08-13T00:00:00Z",
|
||||||
|
schema_bundle.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_config_trees
|
||||||
|
SET decodal_version = '0.2.0', toolchain_fingerprint = 'sha256:legacy'
|
||||||
|
WHERE workspace_id = 'w-config'",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_config_tree_revisions
|
||||||
|
SET toolchain_fingerprint = 'sha256:legacy'
|
||||||
|
WHERE workspace_id = 'w-config' AND revision = ?1",
|
||||||
|
[current.snapshot.revision],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let refreshed = store
|
||||||
|
.ensure_workspace_config_materialized_with_schema(
|
||||||
|
"w-config",
|
||||||
|
"2026-08-14T00:00:00Z",
|
||||||
|
schema_bundle,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(refreshed.snapshot.revision, current.snapshot.revision + 1);
|
||||||
|
assert_eq!(refreshed.snapshot.digest, current.snapshot.digest);
|
||||||
|
assert_eq!(refreshed.contract.decodal_version, DECODAL_VERSION);
|
||||||
|
assert_ne!(refreshed.contract.fingerprint, "sha256:legacy");
|
||||||
|
let prior = store
|
||||||
|
.load_workspace_config_revision("w-config", current.snapshot.revision)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(prior, current.snapshot);
|
||||||
|
let prior_fingerprint = store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT toolchain_fingerprint
|
||||||
|
FROM workspace_config_tree_revisions
|
||||||
|
WHERE workspace_id = 'w-config' AND revision = ?1",
|
||||||
|
[current.snapshot.revision],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(prior_fingerprint, "sha256:legacy");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_toolchain_upgrade_returns_diagnostics_without_mutating_authority() {
|
||||||
|
let store = open_store().await;
|
||||||
|
let schema_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||||
|
"builtin:test",
|
||||||
|
"test",
|
||||||
|
"1",
|
||||||
|
r#"{ test = { value = String default "initial"; }; }"#,
|
||||||
|
)
|
||||||
|
.unwrap()])
|
||||||
|
.unwrap();
|
||||||
|
let current = store
|
||||||
|
.ensure_workspace_config_materialized_with_schema(
|
||||||
|
"w-config",
|
||||||
|
"2026-08-13T00:00:00Z",
|
||||||
|
schema_bundle.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let legacy_entry = ConfigEntry::new(
|
||||||
|
path(MAIN_CONFIG_ENTRYPOINT),
|
||||||
|
ConfigContentType::Decodal,
|
||||||
|
"{ test = {}; custom = 42; }\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let legacy_snapshot =
|
||||||
|
ConfigTreeSnapshot::from_entries(current.snapshot.revision, [legacy_entry.clone()])
|
||||||
|
.unwrap();
|
||||||
|
let manifest_json = serde_json::to_string(&legacy_snapshot.entries).unwrap();
|
||||||
|
store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_config_entries
|
||||||
|
SET content = ?1, content_digest = ?2
|
||||||
|
WHERE workspace_id = 'w-config' AND path = 'main.dcdl'",
|
||||||
|
rusqlite::params![legacy_entry.content, legacy_entry.content_digest],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_config_trees
|
||||||
|
SET tree_digest = ?1, decodal_version = '0.2.0',
|
||||||
|
toolchain_fingerprint = 'sha256:legacy',
|
||||||
|
projection_digest = 'sha256:legacy-projection'
|
||||||
|
WHERE workspace_id = 'w-config'",
|
||||||
|
[legacy_snapshot.digest.as_str()],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_config_tree_revisions
|
||||||
|
SET tree_digest = ?1, toolchain_fingerprint = 'sha256:legacy',
|
||||||
|
projection_digest = 'sha256:legacy-projection', manifest_json = ?2
|
||||||
|
WHERE workspace_id = 'w-config' AND revision = ?3",
|
||||||
|
rusqlite::params![
|
||||||
|
legacy_snapshot.digest,
|
||||||
|
manifest_json,
|
||||||
|
current.snapshot.revision
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = store
|
||||||
|
.ensure_workspace_config_materialized_with_schema(
|
||||||
|
"w-config",
|
||||||
|
"2026-08-14T00:00:00Z",
|
||||||
|
schema_bundle,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
let message = error.to_string();
|
||||||
|
assert!(message.contains("Decodal 0.4.0"));
|
||||||
|
assert!(message.contains("main.dcdl"));
|
||||||
|
assert!(message.contains("constraintviolation"));
|
||||||
|
let persisted = store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT revision, decodal_version, toolchain_fingerprint
|
||||||
|
FROM workspace_config_trees WHERE workspace_id = 'w-config'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, u64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
persisted,
|
||||||
|
(
|
||||||
|
current.snapshot.revision,
|
||||||
|
"0.2.0".into(),
|
||||||
|
"sha256:legacy".into()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0",
|
||||||
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
"@codemirror/state": "npm:@codemirror/state@6.7.1",
|
||||||
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
"@codemirror/view": "npm:@codemirror/view@6.43.8",
|
||||||
"decodal-codemirror": "npm:decodal-codemirror@0.1.6",
|
"decodal-codemirror": "npm:decodal-codemirror@0.3.0",
|
||||||
"clsx": "npm:clsx@2.1.1",
|
"clsx": "npm:clsx@2.1.1",
|
||||||
"cookie": "npm:cookie@0.6.0",
|
"cookie": "npm:cookie@0.6.0",
|
||||||
"devalue": "npm:devalue@5.6.4",
|
"devalue": "npm:devalue@5.6.4",
|
||||||
|
|||||||
Generated
+4
-4
@@ -12,7 +12,7 @@
|
|||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
||||||
"npm:clsx@2.1.1": "2.1.1",
|
"npm:clsx@2.1.1": "2.1.1",
|
||||||
"npm:cookie@0.6.0": "0.6.0",
|
"npm:cookie@0.6.0": "0.6.0",
|
||||||
"npm:decodal-codemirror@0.1.6": "0.1.6_@codemirror+view@6.43.8",
|
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
||||||
"npm:devalue@5.6.4": "5.6.4",
|
"npm:devalue@5.6.4": "5.6.4",
|
||||||
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
||||||
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
||||||
@@ -554,8 +554,8 @@
|
|||||||
"ms"
|
"ms"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"decodal-codemirror@0.1.6_@codemirror+view@6.43.8": {
|
"decodal-codemirror@0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10": {
|
||||||
"integrity": "sha512-XTS5vAY+vTb/yEg5n+1yORtBPuhozG4KO0YGbYEFR8oZX0KghZuHyFEsq/CJMXF223YMC/FQt3SC19NVYGWKMw==",
|
"integrity": "sha512-M+Iod3UAZigpt46TmuLJIlSEBhL6KOOksyVxUOGGdlfKYCMN9oN6YG5G9tjkr3/r+Eq8ig1RipC7VZX5iiACTQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@codemirror/language",
|
"@codemirror/language",
|
||||||
"@codemirror/view",
|
"@codemirror/view",
|
||||||
@@ -1007,7 +1007,7 @@
|
|||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1",
|
||||||
"npm:clsx@2.1.1",
|
"npm:clsx@2.1.1",
|
||||||
"npm:cookie@0.6.0",
|
"npm:cookie@0.6.0",
|
||||||
"npm:decodal-codemirror@0.1.6",
|
"npm:decodal-codemirror@0.3.0",
|
||||||
"npm:devalue@5.6.4",
|
"npm:devalue@5.6.4",
|
||||||
"npm:set-cookie-parser@2.7.2",
|
"npm:set-cookie-parser@2.7.2",
|
||||||
"npm:shiki@3.13.0",
|
"npm:shiki@3.13.0",
|
||||||
|
|||||||
+3
@@ -9,6 +9,8 @@ export function changes_between(base: any, candidate: any): any;
|
|||||||
|
|
||||||
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
|
export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any;
|
||||||
|
|
||||||
|
export function compose_schema_bundle(contributions: any): any;
|
||||||
|
|
||||||
export function evaluate_current(contract: any): any;
|
export function evaluate_current(contract: any): any;
|
||||||
|
|
||||||
export function evaluate_snapshot(snapshot: any, contract: any): any;
|
export function evaluate_snapshot(snapshot: any, contract: any): any;
|
||||||
@@ -27,6 +29,7 @@ export interface InitOutput {
|
|||||||
readonly apply_changes: (a: any) => [number, number, number];
|
readonly apply_changes: (a: any) => [number, number, number];
|
||||||
readonly changes_between: (a: any, b: any) => [number, number, number];
|
readonly changes_between: (a: any, b: any) => [number, number, number];
|
||||||
readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
||||||
|
readonly compose_schema_bundle: (a: any) => [number, number, number];
|
||||||
readonly evaluate_current: (a: any) => [number, number, number];
|
readonly evaluate_current: (a: any) => [number, number, number];
|
||||||
readonly evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
readonly evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
||||||
readonly format_source: (a: number, b: number) => [number, number, number, number];
|
readonly format_source: (a: number, b: number) => [number, number, number, number];
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ export function complete_current(entrypoint, source, utf16_offset, explicit) {
|
|||||||
return takeFromExternrefTable0(ret[0]);
|
return takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} contributions
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export function compose_schema_bundle(contributions) {
|
||||||
|
const ret = wasm.compose_schema_bundle(contributions);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {any} contract
|
* @param {any} contract
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
|
|||||||
Binary file not shown.
+1
@@ -5,6 +5,7 @@ export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: numb
|
|||||||
export const apply_changes: (a: any) => [number, number, number];
|
export const apply_changes: (a: any) => [number, number, number];
|
||||||
export const changes_between: (a: any, b: any) => [number, number, number];
|
export const changes_between: (a: any, b: any) => [number, number, number];
|
||||||
export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
|
||||||
|
export const compose_schema_bundle: (a: any) => [number, number, number];
|
||||||
export const evaluate_current: (a: any) => [number, number, number];
|
export const evaluate_current: (a: any) => [number, number, number];
|
||||||
export const evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
export const evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
||||||
export const format_source: (a: number, b: number) => [number, number, number, number];
|
export const format_source: (a: number, b: number) => [number, number, number, number];
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { decodalLanguage } from "decodal-codemirror";
|
||||||
|
|
||||||
|
Deno.test("editor grammar accepts Decodal 0.4 schema syntax", () => {
|
||||||
|
const source = `
|
||||||
|
import "main.dcdl" as WorkspaceConfigSchema
|
||||||
|
{
|
||||||
|
features = {...{ enabled = Bool; }};
|
||||||
|
web = { enabled = Bool; ...Unknown };
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const tree = decodalLanguage.parser.parse(source);
|
||||||
|
const errors: string[] = [];
|
||||||
|
tree.iterate({
|
||||||
|
enter(node) {
|
||||||
|
if (node.type.isError) {
|
||||||
|
errors.push(`${node.from}..${node.to}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Decodal 0.4 grammar produced parse errors at ${errors.join(", ")}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,19 +1,42 @@
|
|||||||
/// <reference lib="deno.ns" />
|
/// <reference lib="deno.ns" />
|
||||||
|
|
||||||
import { assertEquals } from "jsr:@std/assert";
|
import { assertEquals } from "jsr:@std/assert";
|
||||||
// The generated wasm-bindgen loader is JavaScript with an adjacent declaration file.
|
|
||||||
// @ts-expect-error Deno checks the generated JS implementation rather than its .d.ts.
|
|
||||||
import init, {
|
import init, {
|
||||||
analyze_snapshot,
|
analyze_snapshot,
|
||||||
|
compose_schema_bundle,
|
||||||
evaluate_snapshot,
|
evaluate_snapshot,
|
||||||
} from "../../src/lib/workspace/config-source/generated/config_source_wasm.js";
|
} from "../../src/lib/workspace/config-source/generated/config_source_wasm.js";
|
||||||
import type { ConfigTreeSnapshot, ToolchainContract } from "../../src/lib/workspace/config-source/types.ts";
|
import type {
|
||||||
|
ConfigTreeSnapshot,
|
||||||
|
ToolchainContract,
|
||||||
|
WorkspaceConfigSchemaBundle,
|
||||||
|
} from "../../src/lib/workspace/config-source/types.ts";
|
||||||
|
|
||||||
const bytes = await Deno.readFile(
|
const bytes = await Deno.readFile(
|
||||||
new URL("../../src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm", import.meta.url),
|
new URL("../../src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm", import.meta.url),
|
||||||
);
|
);
|
||||||
await init({ module_or_path: bytes });
|
await init({ module_or_path: bytes });
|
||||||
|
|
||||||
|
async function digestText(text: string): Promise<string> {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||||
|
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toolchainFingerprint(
|
||||||
|
entrypoints: string[],
|
||||||
|
schemaBundle: WorkspaceConfigSchemaBundle,
|
||||||
|
): Promise<string> {
|
||||||
|
return await digestText(JSON.stringify([
|
||||||
|
2,
|
||||||
|
"0.4.0",
|
||||||
|
1,
|
||||||
|
entrypoints,
|
||||||
|
1,
|
||||||
|
schemaBundle.fingerprint,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot: ConfigTreeSnapshot = {
|
const snapshot: ConfigTreeSnapshot = {
|
||||||
revision: 4,
|
revision: 4,
|
||||||
digest: "sha256:test-tree",
|
digest: "sha256:test-tree",
|
||||||
@@ -33,13 +56,15 @@ const snapshot: ConfigTreeSnapshot = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptySchemaBundle = compose_schema_bundle([]) as WorkspaceConfigSchemaBundle;
|
||||||
const contract: ToolchainContract = {
|
const contract: ToolchainContract = {
|
||||||
contract_version: 1,
|
contract_version: 2,
|
||||||
decodal_version: "0.2.0",
|
decodal_version: "0.4.0",
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
entrypoints: ["workspace.dcdl"],
|
entrypoints: ["workspace.dcdl"],
|
||||||
import_policy_version: 1,
|
import_policy_version: 1,
|
||||||
fingerprint: "sha256:test-contract",
|
schema_bundle: emptySchemaBundle,
|
||||||
|
fingerprint: await toolchainFingerprint(["workspace.dcdl"], emptySchemaBundle),
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.test("generated WASM evaluates the same virtual import contract", () => {
|
Deno.test("generated WASM evaluates the same virtual import contract", () => {
|
||||||
@@ -65,3 +90,73 @@ Deno.test("generated WASM diagnostics carry snapshot provenance", () => {
|
|||||||
assertEquals(diagnostics[0].tree_digest, "sha256:test-tree");
|
assertEquals(diagnostics[0].tree_digest, "sha256:test-tree");
|
||||||
assertEquals(diagnostics[0].kind, "syntax");
|
assertEquals(diagnostics[0].kind, "syntax");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const featuresSchema = "{ features = {...{ enabled = Bool; }}; }";
|
||||||
|
const webSchema = "{ web = { enabled = Bool; ...Unknown }; }";
|
||||||
|
const schemaBundle = compose_schema_bundle([
|
||||||
|
{
|
||||||
|
provider_id: "builtin:features",
|
||||||
|
namespace: "features",
|
||||||
|
version: "1",
|
||||||
|
source: featuresSchema,
|
||||||
|
source_digest: await digestText(featuresSchema),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider_id: "builtin:web",
|
||||||
|
namespace: "web",
|
||||||
|
version: "1",
|
||||||
|
source: webSchema,
|
||||||
|
source_digest: await digestText(webSchema),
|
||||||
|
},
|
||||||
|
]) as WorkspaceConfigSchemaBundle;
|
||||||
|
|
||||||
|
function schemaSnapshot(source: string): ConfigTreeSnapshot {
|
||||||
|
return {
|
||||||
|
revision: 7,
|
||||||
|
digest: "sha256:schema-tree",
|
||||||
|
entries: {
|
||||||
|
"main.dcdl": {
|
||||||
|
path: "main.dcdl",
|
||||||
|
content_type: "decodal",
|
||||||
|
content: source,
|
||||||
|
content_digest: "sha256:main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaContract: ToolchainContract = {
|
||||||
|
contract_version: 2,
|
||||||
|
decodal_version: "0.4.0",
|
||||||
|
schema_version: 1,
|
||||||
|
entrypoints: ["main.dcdl"],
|
||||||
|
import_policy_version: 1,
|
||||||
|
schema_bundle: schemaBundle,
|
||||||
|
fingerprint: await toolchainFingerprint(["main.dcdl"], schemaBundle),
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.test("generated WASM applies Decodal 0.4 typed maps and explicit object rest", () => {
|
||||||
|
const result = evaluate_snapshot(
|
||||||
|
schemaSnapshot(
|
||||||
|
"{ features = { console = { enabled = true; }; }; web = { enabled = true; extension_value = 42; }; }",
|
||||||
|
),
|
||||||
|
schemaContract,
|
||||||
|
) as { projections: Array<{ data_json: Record<string, unknown> }> };
|
||||||
|
assertEquals(result.projections[0].data_json, {
|
||||||
|
features: { console: { enabled: true } },
|
||||||
|
web: { enabled: true, extension_value: 42 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("generated WASM rejects unknown root fields with source provenance", () => {
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
evaluate_snapshot(schemaSnapshot("{ features = {}; custom = 42; }"), schemaContract);
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
const diagnostics = thrown as Array<{ path: string; kind: string }>;
|
||||||
|
assertEquals(Array.isArray(diagnostics), true);
|
||||||
|
assertEquals(diagnostics[0].path, "main.dcdl");
|
||||||
|
assertEquals(diagnostics[0].kind, "constraintviolation");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user