config: enforce browser preflight and generated DTOs

This commit is contained in:
2026-08-14 01:27:29 +09:00
parent fa9bd8207f
commit 544abbbf56
29 changed files with 259 additions and 128 deletions
+27 -1
View File
@@ -32,6 +32,13 @@ pub fn apply_changes(changes: JsValue) -> Result<JsValue, JsValue> {
})
}
#[wasm_bindgen]
pub fn changes_between(base: JsValue, candidate: JsValue) -> Result<JsValue, JsValue> {
let base: ConfigTreeSnapshot = decode(base)?;
let candidate: ConfigTreeSnapshot = decode(candidate)?;
encode(base.changes_to(&candidate))
}
#[wasm_bindgen]
pub fn evaluate_current(contract: JsValue) -> Result<JsValue, JsValue> {
let contract: ToolchainContract = decode(contract)?;
@@ -68,7 +75,7 @@ struct WasmCompletionItem {
pub fn complete_current(
entrypoint: String,
source: String,
utf8_byte_offset: usize,
utf16_offset: usize,
explicit: bool,
) -> Result<JsValue, JsValue> {
let entrypoint = VirtualPath::parse(entrypoint).map_err(js_error)?;
@@ -77,6 +84,7 @@ pub fn complete_current(
let snapshot = session
.as_ref()
.ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?;
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
let result = SnapshotEnvironment::new(snapshot.clone())
.complete(&entrypoint, &source, utf8_byte_offset, explicit)
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?
@@ -128,6 +136,24 @@ pub fn format_source(source: String) -> Result<String, JsValue> {
.map_err(|error| JsValue::from_str(&error))
}
fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result<usize, JsValue> {
let mut units = 0usize;
for (byte_offset, character) in source.char_indices() {
if units == utf16_offset {
return Ok(byte_offset);
}
units += character.len_utf16();
if units > utf16_offset {
return Err(JsValue::from_str("UTF-16 offset splits a surrogate pair"));
}
}
if units == utf16_offset {
Ok(source.len())
} else {
Err(JsValue::from_str("UTF-16 offset is outside the source"))
}
}
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
}
+1
View File
@@ -13,6 +13,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
ts-rs = "12.0.1"
[dev-dependencies]
pretty_assertions = "1"
+71 -11
View File
@@ -19,7 +19,7 @@ pub const MAX_ENTRY_BYTES: usize = 256 * 1024;
pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_PATH_BYTES: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
#[serde(transparent)]
pub struct VirtualPath(String);
@@ -71,7 +71,7 @@ impl fmt::Display for VirtualPath {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
pub enum ConfigContentType {
Decodal,
@@ -87,7 +87,7 @@ impl ConfigContentType {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ConfigEntry {
pub path: VirtualPath,
pub content_type: ConfigContentType,
@@ -114,8 +114,9 @@ impl ConfigEntry {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ConfigTreeSnapshot {
#[ts(type = "number")]
pub revision: u64,
pub digest: String,
pub entries: BTreeMap<VirtualPath, ConfigEntry>,
@@ -174,6 +175,38 @@ impl ConfigTreeSnapshot {
self.entries.get(path)
}
pub fn changes_to(&self, candidate: &Self) -> Vec<ConfigTreeChange> {
let mut changes = Vec::new();
for (path, base_entry) in &self.entries {
match candidate.entries.get(path) {
None => changes.push(ConfigTreeChange::Delete {
path: path.clone(),
expected_digest: base_entry.content_digest.clone(),
}),
Some(candidate_entry)
if candidate_entry.content_digest != base_entry.content_digest =>
{
changes.push(ConfigTreeChange::Update {
path: path.clone(),
expected_digest: base_entry.content_digest.clone(),
content: candidate_entry.content.clone(),
});
}
Some(_) => {}
}
}
for (path, entry) in &candidate.entries {
if !self.entries.contains_key(path) {
changes.push(ConfigTreeChange::Create {
path: path.clone(),
content_type: entry.content_type,
content: entry.content.clone(),
});
}
}
changes
}
pub fn apply(&self, changes: &[ConfigTreeChange]) -> Result<Self, ConfigTreeError> {
if changes.len() > MAX_CHANGE_COUNT {
return Err(ConfigTreeError::LimitExceeded("change count"));
@@ -253,7 +286,7 @@ impl ConfigTreeSnapshot {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ConfigTreeChange {
Create {
@@ -288,7 +321,7 @@ impl ConfigTreeChange {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ToolchainContract {
pub contract_version: u32,
pub decodal_version: String,
@@ -329,21 +362,22 @@ impl ToolchainContract {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ConfigSpan {
pub start_byte: u32,
pub end_byte: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ConfigDiagnosticLabel {
pub span: ConfigSpan,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
pub struct ConfigDiagnostic {
pub path: VirtualPath,
#[ts(type = "number")]
pub revision: u64,
pub tree_digest: String,
pub kind: String,
@@ -353,14 +387,16 @@ pub struct ConfigDiagnostic {
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct EvaluatedProjection {
pub entrypoint: VirtualPath,
#[ts(type = "unknown")]
pub data_json: serde_json::Value,
pub projection_digest: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
pub struct EvaluationResult {
pub projections: Vec<EvaluatedProjection>,
pub projection_digest: String,
@@ -779,6 +815,30 @@ pub enum ConfigTreeError {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ts_rs::TS;
#[test]
fn exports_typescript_contract() {
let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/workspace/config-source/generated/types");
std::fs::create_dir_all(&output).unwrap();
macro_rules! export {
($type:ty) => {
<$type>::export_all(&ts_rs::Config::default().with_out_dir(&output)).unwrap();
};
}
export!(VirtualPath);
export!(ConfigContentType);
export!(ConfigEntry);
export!(ConfigTreeSnapshot);
export!(ConfigTreeChange);
export!(ToolchainContract);
export!(ConfigSpan);
export!(ConfigDiagnosticLabel);
export!(ConfigDiagnostic);
export!(EvaluatedProjection);
export!(EvaluationResult);
}
fn path(value: &str) -> VirtualPath {
VirtualPath::parse(value).unwrap()
+22 -4
View File
@@ -11,15 +11,18 @@ use crate::{Error, Result, SqliteWorkspaceStore};
pub const DEFAULT_CONFIG_ENTRYPOINT: &str = "workspace.dcdl";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct WorkspaceConfigState {
pub snapshot: ConfigTreeSnapshot,
pub contract: ToolchainContract,
pub projection_digest: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct EvaluatedConfigCandidate {
#[ts(type = "number")]
pub base_revision: u64,
pub base_digest: String,
pub snapshot: ConfigTreeSnapshot,
@@ -27,8 +30,10 @@ pub struct EvaluatedConfigCandidate {
pub evaluation: EvaluationResult,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct ConfigCommitRequest {
#[ts(type = "number")]
pub base_revision: u64,
pub base_digest: String,
pub changes: Vec<ConfigTreeChange>,
@@ -36,7 +41,8 @@ pub struct ConfigCommitRequest {
pub toolchain_fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct ConfigPreviewRequest {
pub changes: Vec<ConfigTreeChange>,
pub entrypoints: Vec<VirtualPath>,
@@ -494,6 +500,18 @@ mod tests {
assert!(store.load_workspace_config("w-config").unwrap().is_none());
}
#[test]
fn exports_typescript_transport_contract() {
use ts_rs::TS;
let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../web/workspace/src/lib/workspace/config-source/generated/types");
let config = ts_rs::Config::default().with_out_dir(&output);
WorkspaceConfigState::export_all(&config).unwrap();
EvaluatedConfigCandidate::export_all(&config).unwrap();
ConfigCommitRequest::export_all(&config).unwrap();
ConfigPreviewRequest::export_all(&config).unwrap();
}
#[test]
fn migration_creates_config_authority_without_changing_applied_migrations() {
let store = SqliteWorkspaceStore::in_memory().unwrap();