config: enforce browser preflight and generated DTOs
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user