server: require main config entrypoint

This commit is contained in:
2026-08-14 04:36:26 +09:00
parent c98048b97b
commit 7a8bd7717e
4 changed files with 477 additions and 143 deletions
+342 -105
View File
@@ -9,7 +9,20 @@ use serde::{Deserialize, Serialize};
use crate::{Error, Result, SqliteWorkspaceStore}; use crate::{Error, Result, SqliteWorkspaceStore};
pub const DEFAULT_CONFIG_ENTRYPOINT: &str = "workspace.dcdl"; pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl";
pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n";
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(
DEFAULT_SCHEMA_VERSION,
vec![main_config_path()],
DEFAULT_IMPORT_POLICY_VERSION,
)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)] #[ts(export)]
@@ -49,6 +62,34 @@ pub struct ConfigPreviewRequest {
} }
impl SqliteWorkspaceStore { impl SqliteWorkspaceStore {
pub fn ensure_workspace_config_materialized(
&self,
workspace_id: &str,
materialized_at: &str,
) -> Result<WorkspaceConfigState> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let workspace_exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
[workspace_id],
|row| row.get(0),
)?;
if !workspace_exists {
return Err(Error::WorkspaceIdMismatch);
}
let state = match load_state(&tx, workspace_id)? {
Some(state) => state,
None => {
let state = initial_state()?;
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
state
}
};
tx.commit()?;
Ok(state)
})
}
pub fn load_workspace_config( pub fn load_workspace_config(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -93,7 +134,8 @@ impl SqliteWorkspaceStore {
) -> Result<EvaluatedConfigCandidate> { ) -> Result<EvaluatedConfigCandidate> {
let current = self let current = self
.load_workspace_config(workspace_id)? .load_workspace_config(workspace_id)?
.unwrap_or_else(empty_state); .ok_or_else(config_not_materialized)?;
validate_entrypoint_request(&request.entrypoints)?;
if current.snapshot.revision != request.base_revision if current.snapshot.revision != request.base_revision
|| current.snapshot.digest != request.base_digest || current.snapshot.digest != request.base_digest
{ {
@@ -102,18 +144,14 @@ impl SqliteWorkspaceStore {
current.snapshot.revision current.snapshot.revision
))); )));
} }
let expected_contract = ToolchainContract::new( let expected_contract = main_config_contract();
DEFAULT_SCHEMA_VERSION,
request.entrypoints.clone(),
DEFAULT_IMPORT_POLICY_VERSION,
);
if expected_contract.fingerprint != request.toolchain_fingerprint { if expected_contract.fingerprint != request.toolchain_fingerprint {
return Err(config_conflict(format!( return Err(config_conflict(format!(
"toolchain fingerprint mismatch; current fingerprint is {}", "toolchain fingerprint mismatch; current fingerprint is {}",
expected_contract.fingerprint expected_contract.fingerprint
))); )));
} }
evaluate_candidate(current, &request.changes, request.entrypoints.clone()) evaluate_candidate(current, &request.changes)
} }
pub fn preview_workspace_config( pub fn preview_workspace_config(
@@ -121,10 +159,11 @@ impl SqliteWorkspaceStore {
workspace_id: &str, workspace_id: &str,
request: &ConfigPreviewRequest, request: &ConfigPreviewRequest,
) -> Result<EvaluatedConfigCandidate> { ) -> Result<EvaluatedConfigCandidate> {
validate_entrypoint_request(&request.entrypoints)?;
let current = self let current = self
.load_workspace_config(workspace_id)? .load_workspace_config(workspace_id)?
.unwrap_or_else(empty_state); .ok_or_else(config_not_materialized)?;
evaluate_candidate(current, &request.changes, request.entrypoints.clone()) evaluate_candidate(current, &request.changes)
} }
pub fn commit_evaluated_workspace_config( pub fn commit_evaluated_workspace_config(
@@ -142,7 +181,7 @@ impl SqliteWorkspaceStore {
if !workspace_exists { if !workspace_exists {
return Err(Error::WorkspaceIdMismatch); return Err(Error::WorkspaceIdMismatch);
} }
let current = load_state(&tx, workspace_id)?.unwrap_or_else(empty_state); let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?;
if current.snapshot.revision != candidate.base_revision if current.snapshot.revision != candidate.base_revision
|| current.snapshot.digest != candidate.base_digest || current.snapshot.digest != candidate.base_digest
{ {
@@ -165,6 +204,7 @@ impl SqliteWorkspaceStore {
revision = excluded.revision, revision = excluded.revision,
tree_digest = excluded.tree_digest, tree_digest = excluded.tree_digest,
schema_version = excluded.schema_version, schema_version = excluded.schema_version,
entrypoints_json = excluded.entrypoints_json,
decodal_version = excluded.decodal_version, decodal_version = excluded.decodal_version,
import_policy_version = excluded.import_policy_version, import_policy_version = excluded.import_policy_version,
toolchain_fingerprint = excluded.toolchain_fingerprint, toolchain_fingerprint = excluded.toolchain_fingerprint,
@@ -241,14 +281,11 @@ impl SqliteWorkspaceStore {
fn evaluate_candidate( fn evaluate_candidate(
current: WorkspaceConfigState, current: WorkspaceConfigState,
changes: &[ConfigTreeChange], changes: &[ConfigTreeChange],
entrypoints: Vec<VirtualPath>,
) -> Result<EvaluatedConfigCandidate> { ) -> Result<EvaluatedConfigCandidate> {
reject_main_entrypoint_mutation(changes)?;
let snapshot = current.snapshot.apply(changes).map_err(config_error)?; let snapshot = current.snapshot.apply(changes).map_err(config_error)?;
let contract = ToolchainContract::new( ensure_main_entrypoint(&snapshot)?;
DEFAULT_SCHEMA_VERSION, let contract = main_config_contract();
entrypoints,
DEFAULT_IMPORT_POLICY_VERSION,
);
let evaluation = SnapshotEnvironment::new(snapshot.clone()) let evaluation = SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract) .evaluate_contract(&contract)
.map_err(|diagnostics| { .map_err(|diagnostics| {
@@ -266,7 +303,7 @@ fn evaluate_candidate(
}) })
} }
fn load_state( pub(crate) fn load_state(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
workspace_id: &str, workspace_id: &str,
) -> Result<Option<WorkspaceConfigState>> { ) -> Result<Option<WorkspaceConfigState>> {
@@ -352,15 +389,149 @@ fn load_state(
})) }))
} }
fn empty_state() -> WorkspaceConfigState { pub(crate) fn initial_state() -> Result<WorkspaceConfigState> {
WorkspaceConfigState { let path = main_config_path();
snapshot: ConfigTreeSnapshot::empty(), let snapshot = ConfigTreeSnapshot::empty()
contract: ToolchainContract::new( .apply(&[ConfigTreeChange::Create {
DEFAULT_SCHEMA_VERSION, path,
Vec::new(), content_type: ConfigContentType::Decodal,
DEFAULT_IMPORT_POLICY_VERSION, content: DEFAULT_MAIN_CONFIG_SOURCE.to_string(),
), }])
projection_digest: config_source::digest_bytes(b"[]"), .map_err(config_error)?;
let contract = main_config_contract();
let projection_digest = SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.map_err(|diagnostics| {
Error::InvalidInput(
serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "virtual config evaluation failed".to_string()),
)
})?
.projection_digest;
Ok(WorkspaceConfigState {
snapshot,
contract,
projection_digest,
})
}
pub(crate) fn insert_materialized_state(
tx: &rusqlite::Connection,
workspace_id: &str,
state: &WorkspaceConfigState,
materialized_at: &str,
) -> Result<()> {
let entrypoints_json = serde_json::to_string(&state.contract.entrypoints)
.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,
],
)?;
for entry in state.snapshot.entries.values() {
tx.execute(
"INSERT INTO workspace_config_entries (
workspace_id, path, content_type, content, content_digest
) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
workspace_id,
entry.path.as_str(),
content_type_label(entry.content_type),
entry.content,
entry.content_digest,
],
)?;
}
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(())
}
fn config_not_materialized() -> Error {
Error::RegistryInconsistency("workspace config tree is not materialized".to_string())
}
fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> {
if entrypoints == [main_config_path()] {
Ok(())
} else {
Err(Error::InvalidInput(format!(
"workspace config entrypoints must be exactly [{MAIN_CONFIG_ENTRYPOINT}]"
)))
}
}
fn reject_main_entrypoint_mutation(changes: &[ConfigTreeChange]) -> Result<()> {
let main = main_config_path();
for change in changes {
match change {
ConfigTreeChange::Delete { path, .. } if path == &main => {
return Err(Error::InvalidInput(format!(
"{MAIN_CONFIG_ENTRYPOINT} is the required Workspace entrypoint and cannot be deleted"
)));
}
ConfigTreeChange::Rename { from, to, .. } if from == &main || to == &main => {
return Err(Error::InvalidInput(format!(
"{MAIN_CONFIG_ENTRYPOINT} is the required Workspace entrypoint and cannot be renamed"
)));
}
ConfigTreeChange::Create { path, .. } if path == &main => {
return Err(Error::WorkspaceConfigConflict(format!(
"{MAIN_CONFIG_ENTRYPOINT} is already materialized"
)));
}
_ => {}
}
}
Ok(())
}
fn ensure_main_entrypoint(snapshot: &ConfigTreeSnapshot) -> Result<()> {
if snapshot.entries.contains_key(&main_config_path()) {
Ok(())
} else {
Err(Error::RegistryInconsistency(format!(
"workspace config tree is missing required entrypoint {MAIN_CONFIG_ENTRYPOINT}"
)))
} }
} }
@@ -405,30 +576,122 @@ mod tests {
} }
} }
async fn open_store() -> SqliteWorkspaceStore {
let store = SqliteWorkspaceStore::in_memory().unwrap();
store.upsert_workspace(&workspace()).await.unwrap();
store
}
fn path(value: &str) -> VirtualPath { fn path(value: &str) -> VirtualPath {
VirtualPath::parse(value).unwrap() VirtualPath::parse(value).unwrap()
} }
fn commit_request(
current: &WorkspaceConfigState,
changes: Vec<ConfigTreeChange>,
) -> ConfigCommitRequest {
ConfigCommitRequest {
base_revision: current.snapshot.revision,
base_digest: current.snapshot.digest.clone(),
changes,
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: current.contract.fingerprint.clone(),
}
}
fn update_main(current: &WorkspaceConfigState, content: &str) -> ConfigTreeChange {
let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
ConfigTreeChange::Update {
path: path(MAIN_CONFIG_ENTRYPOINT),
expected_digest: main.content_digest.clone(),
content: content.to_string(),
}
}
#[tokio::test]
async fn workspace_materializes_main_entrypoint() {
let store = open_store().await;
let current = store.load_workspace_config("w-config").unwrap().unwrap();
assert_eq!(current.snapshot.revision, 0);
assert_eq!(
current.contract.entrypoints,
vec![path(MAIN_CONFIG_ENTRYPOINT)]
);
assert_eq!(
current
.snapshot
.get(&path(MAIN_CONFIG_ENTRYPOINT))
.unwrap()
.content,
DEFAULT_MAIN_CONFIG_SOURCE
);
}
#[tokio::test]
async fn required_main_entrypoint_cannot_be_deleted_or_renamed() {
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();
for change in [
ConfigTreeChange::Delete {
path: path(MAIN_CONFIG_ENTRYPOINT),
expected_digest: main.content_digest.clone(),
},
ConfigTreeChange::Rename {
from: path(MAIN_CONFIG_ENTRYPOINT),
to: path("other.dcdl"),
expected_digest: main.content_digest.clone(),
},
] {
let error = store
.preview_workspace_config(
"w-config",
&ConfigPreviewRequest {
changes: vec![change],
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
},
)
.unwrap_err();
assert!(error.to_string().contains("cannot be"));
}
}
#[tokio::test]
async fn browser_cannot_replace_server_owned_entrypoint_contract() {
let store = open_store().await;
let error = store
.preview_workspace_config(
"w-config",
&ConfigPreviewRequest {
changes: Vec::new(),
entrypoints: vec![path("other.dcdl")],
},
)
.unwrap_err();
assert!(error.to_string().contains("must be exactly [main.dcdl]"));
}
#[tokio::test] #[tokio::test]
async fn invalid_candidate_is_never_persisted() { async fn invalid_candidate_is_never_persisted() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
store.upsert_workspace(&workspace()).await.unwrap(); store.upsert_workspace(&workspace()).await.unwrap();
let current = ConfigTreeSnapshot::empty(); let current = store.load_workspace_config("w-config").unwrap().unwrap();
let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
let error = store let error = store
.evaluate_and_commit_workspace_config( .evaluate_and_commit_workspace_config(
"w-config", "w-config",
&ConfigCommitRequest { &ConfigCommitRequest {
base_revision: 0, base_revision: current.snapshot.revision,
base_digest: current.digest, base_digest: current.snapshot.digest.clone(),
changes: vec![ConfigTreeChange::Create { changes: vec![ConfigTreeChange::Update {
path: path(DEFAULT_CONFIG_ENTRYPOINT), path: path(MAIN_CONFIG_ENTRYPOINT),
content_type: ConfigContentType::Decodal, expected_digest: main.content_digest.clone(),
content: "{ broken = ; }".into(), content: "{ broken = ; }".into(),
}], }],
entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: ToolchainContract::new( toolchain_fingerprint: ToolchainContract::new(
DEFAULT_SCHEMA_VERSION, DEFAULT_SCHEMA_VERSION,
vec![path(DEFAULT_CONFIG_ENTRYPOINT)], vec![path(MAIN_CONFIG_ENTRYPOINT)],
DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_IMPORT_POLICY_VERSION,
) )
.fingerprint, .fingerprint,
@@ -436,33 +699,18 @@ mod tests {
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(error, Error::InvalidInput(_))); assert!(matches!(error, Error::InvalidInput(_)));
assert!(store.load_workspace_config("w-config").unwrap().is_none()); assert!(store.load_workspace_config("w-config").unwrap().is_some());
} }
#[tokio::test] #[tokio::test]
async fn valid_candidate_commits_snapshot_revision_and_provenance_atomically() { async fn valid_candidate_commits_snapshot_revision_and_provenance_atomically() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
store.upsert_workspace(&workspace()).await.unwrap(); store.upsert_workspace(&workspace()).await.unwrap();
let empty = ConfigTreeSnapshot::empty(); let current = store.load_workspace_config("w-config").unwrap().unwrap();
let committed = store let committed = store
.evaluate_and_commit_workspace_config( .evaluate_and_commit_workspace_config(
"w-config", "w-config",
&ConfigCommitRequest { &commit_request(&current, vec![update_main(&current, "{ answer = 42; }")]),
base_revision: 0,
base_digest: empty.digest,
changes: vec![ConfigTreeChange::Create {
path: path(DEFAULT_CONFIG_ENTRYPOINT),
content_type: ConfigContentType::Decodal,
content: "{ answer = 42; }".into(),
}],
entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: ToolchainContract::new(
DEFAULT_SCHEMA_VERSION,
vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
DEFAULT_IMPORT_POLICY_VERSION,
)
.fingerprint,
},
) )
.unwrap(); .unwrap();
assert_eq!(committed.snapshot.revision, 1); assert_eq!(committed.snapshot.revision, 1);
@@ -476,23 +724,8 @@ mod tests {
async fn stale_cas_cannot_overwrite_newer_tree() { async fn stale_cas_cannot_overwrite_newer_tree() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
store.upsert_workspace(&workspace()).await.unwrap(); store.upsert_workspace(&workspace()).await.unwrap();
let empty = ConfigTreeSnapshot::empty(); let current = store.load_workspace_config("w-config").unwrap().unwrap();
let request = ConfigCommitRequest { let request = commit_request(&current, vec![update_main(&current, "{ answer = 42; }")]);
base_revision: 0,
base_digest: empty.digest,
changes: vec![ConfigTreeChange::Create {
path: path(DEFAULT_CONFIG_ENTRYPOINT),
content_type: ConfigContentType::Decodal,
content: "{ answer = 42; }".into(),
}],
entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: ToolchainContract::new(
DEFAULT_SCHEMA_VERSION,
vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
DEFAULT_IMPORT_POLICY_VERSION,
)
.fingerprint,
};
let candidate = store let candidate = store
.evaluate_workspace_config_candidate("w-config", &request) .evaluate_workspace_config_candidate("w-config", &request)
.unwrap(); .unwrap();
@@ -509,32 +742,14 @@ mod tests {
async fn committed_revision_remains_retrievable_after_later_commit() { async fn committed_revision_remains_retrievable_after_later_commit() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
store.upsert_workspace(&workspace()).await.unwrap(); store.upsert_workspace(&workspace()).await.unwrap();
let empty = ConfigTreeSnapshot::empty(); let current = store.load_workspace_config("w-config").unwrap().unwrap();
let contract = ToolchainContract::new(
DEFAULT_SCHEMA_VERSION,
vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
DEFAULT_IMPORT_POLICY_VERSION,
);
let first = store let first = store
.evaluate_and_commit_workspace_config( .evaluate_and_commit_workspace_config(
"w-config", "w-config",
&ConfigCommitRequest { &commit_request(&current, vec![update_main(&current, "{ answer = 1; }")]),
base_revision: 0,
base_digest: empty.digest,
changes: vec![ConfigTreeChange::Create {
path: path(DEFAULT_CONFIG_ENTRYPOINT),
content_type: ConfigContentType::Decodal,
content: "{ answer = 1; }".into(),
}],
entrypoints: contract.entrypoints.clone(),
toolchain_fingerprint: contract.fingerprint.clone(),
},
) )
.unwrap(); .unwrap();
let entry = first let entry = first.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap();
.snapshot
.get(&path(DEFAULT_CONFIG_ENTRYPOINT))
.unwrap();
store store
.evaluate_and_commit_workspace_config( .evaluate_and_commit_workspace_config(
"w-config", "w-config",
@@ -542,12 +757,12 @@ mod tests {
base_revision: first.snapshot.revision, base_revision: first.snapshot.revision,
base_digest: first.snapshot.digest.clone(), base_digest: first.snapshot.digest.clone(),
changes: vec![ConfigTreeChange::Update { changes: vec![ConfigTreeChange::Update {
path: path(DEFAULT_CONFIG_ENTRYPOINT), path: path(MAIN_CONFIG_ENTRYPOINT),
expected_digest: entry.content_digest.clone(), expected_digest: entry.content_digest.clone(),
content: "{ answer = 2; }".into(), content: "{ answer = 2; }".into(),
}], }],
entrypoints: contract.entrypoints, entrypoints: first.contract.entrypoints.clone(),
toolchain_fingerprint: contract.fingerprint, toolchain_fingerprint: first.contract.fingerprint.clone(),
}, },
) )
.unwrap(); .unwrap();
@@ -562,25 +777,47 @@ mod tests {
async fn commit_rejects_mismatched_toolchain_fingerprint() { async fn commit_rejects_mismatched_toolchain_fingerprint() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
store.upsert_workspace(&workspace()).await.unwrap(); store.upsert_workspace(&workspace()).await.unwrap();
let empty = ConfigTreeSnapshot::empty(); let current = store.load_workspace_config("w-config").unwrap().unwrap();
let error = store let error = store
.evaluate_and_commit_workspace_config( .evaluate_and_commit_workspace_config(
"w-config", "w-config",
&ConfigCommitRequest { &ConfigCommitRequest {
base_revision: 0, base_revision: current.snapshot.revision,
base_digest: empty.digest, base_digest: current.snapshot.digest.clone(),
changes: vec![ConfigTreeChange::Create { changes: vec![update_main(&current, "{ answer = 42; }")],
path: path(DEFAULT_CONFIG_ENTRYPOINT), entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)],
content_type: ConfigContentType::Decodal,
content: "{ answer = 42; }".into(),
}],
entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: "sha256:stale-toolchain".into(), toolchain_fingerprint: "sha256:stale-toolchain".into(),
}, },
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(error, Error::WorkspaceConfigConflict(_))); assert!(matches!(error, Error::WorkspaceConfigConflict(_)));
assert!(store.load_workspace_config("w-config").unwrap().is_none()); assert!(store.load_workspace_config("w-config").unwrap().is_some());
}
#[tokio::test]
async fn migration_materializes_main_for_existing_workspace_without_config() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::store::configure_sqlite(&conn).unwrap();
crate::store::apply_migrations_through(&conn, 30).unwrap();
conn.execute(
"INSERT INTO workspaces (
workspace_id, display_name, state, created_at, updated_at
) VALUES ('legacy', 'Legacy', 'active', '2026-08-06T00:00:00Z', '2026-08-06T00:00:00Z')",
[],
)
.unwrap();
crate::store::materialize_main_config_entrypoint(&conn).unwrap();
let state = load_state(&conn, "legacy").unwrap().unwrap();
assert!(
state
.snapshot
.entries
.contains_key(&path(MAIN_CONFIG_ENTRYPOINT))
);
assert_eq!(
state.contract.entrypoints,
vec![path(MAIN_CONFIG_ENTRYPOINT)]
);
} }
#[test] #[test]
@@ -596,7 +833,7 @@ mod tests {
} }
#[test] #[test]
fn migration_creates_config_authority_without_changing_applied_migrations() { fn migration_creates_config_authority_tables() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
store store
.with_conn(|conn| { .with_conn(|conn| {
+111 -21
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use flow::{CompiledFlowDefinition, FlowSourceKind, compile_flow_source}; use flow::{CompiledFlowDefinition, FlowSourceKind, compile_flow_source};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@@ -171,6 +171,11 @@ const MIGRATIONS: &[Migration] = &[
name: "create Workspace virtual config source authority", name: "create Workspace virtual config source authority",
apply: create_workspace_config_source_authority, apply: create_workspace_config_source_authority,
}, },
Migration {
version: 31,
name: "materialize required main.dcdl Workspace config entrypoint",
apply: materialize_main_config_entrypoint,
},
]; ];
struct Migration { struct Migration {
@@ -882,6 +887,23 @@ impl SqliteWorkspaceStore {
f(&mut conn) f(&mut conn)
} }
fn materialize_workspace_config(&self, workspace_id: &str, created_at: &str) -> Result<()> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
if crate::config_source::load_state(&tx, workspace_id)?.is_none() {
let state = crate::config_source::initial_state()?;
crate::config_source::insert_materialized_state(
&tx,
workspace_id,
&state,
created_at,
)?;
}
tx.commit()?;
Ok(())
})
}
pub fn upsert_trusted_runtime(&self, record: &TrustedRuntimeRecord) -> Result<()> { pub fn upsert_trusted_runtime(&self, record: &TrustedRuntimeRecord) -> Result<()> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
@@ -966,7 +988,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
], ],
)?; )?;
Ok(()) Ok(())
}) })?;
self.materialize_workspace_config(&record.workspace_id, &record.created_at)
} }
async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>> { async fn get_workspace(&self, workspace_id: &str) -> Result<Option<WorkspaceRecord>> {
@@ -4261,7 +4284,7 @@ CREATE INDEX IF NOT EXISTS idx_device_login_user_code ON device_login_flows(user
Ok(()) Ok(())
} }
fn configure_sqlite(conn: &Connection) -> Result<()> { pub(crate) fn configure_sqlite(conn: &Connection) -> Result<()> {
conn.busy_timeout(Duration::from_millis(5_000))?; conn.busy_timeout(Duration::from_millis(5_000))?;
conn.execute_batch( conn.execute_batch(
r#" r#"
@@ -4535,7 +4558,7 @@ fn current_schema_version(conn: &Connection) -> Result<i64> {
fn create_workspace_config_source_authority(conn: &Connection) -> Result<()> { fn create_workspace_config_source_authority(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
r#" r#"
CREATE TABLE workspace_config_trees ( CREATE TABLE IF NOT EXISTS workspace_config_trees (
workspace_id TEXT PRIMARY KEY, workspace_id TEXT PRIMARY KEY,
revision INTEGER NOT NULL CHECK (revision >= 0), revision INTEGER NOT NULL CHECK (revision >= 0),
tree_digest TEXT NOT NULL, tree_digest TEXT NOT NULL,
@@ -4548,7 +4571,7 @@ fn create_workspace_config_source_authority(conn: &Connection) -> Result<()> {
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
); );
CREATE TABLE workspace_config_entries ( CREATE TABLE IF NOT EXISTS workspace_config_entries (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
path TEXT NOT NULL, path TEXT NOT NULL,
content_type TEXT NOT NULL, content_type TEXT NOT NULL,
@@ -4557,9 +4580,9 @@ fn create_workspace_config_source_authority(conn: &Connection) -> Result<()> {
PRIMARY KEY (workspace_id, path), PRIMARY KEY (workspace_id, path),
FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE
); );
CREATE INDEX idx_workspace_config_entries_prefix CREATE INDEX IF NOT EXISTS idx_workspace_config_entries_prefix
ON workspace_config_entries(workspace_id, path); ON workspace_config_entries(workspace_id, path);
CREATE TABLE workspace_config_tree_revisions ( CREATE TABLE IF NOT EXISTS workspace_config_tree_revisions (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
revision INTEGER NOT NULL, revision INTEGER NOT NULL,
tree_digest TEXT NOT NULL, tree_digest TEXT NOT NULL,
@@ -4592,12 +4615,75 @@ fn create_worker_mutation_source_proof_replay_guard(conn: &Connection) -> Result
Ok(()) Ok(())
} }
fn apply_migrations(conn: &Connection) -> Result<()> { pub(crate) fn materialize_main_config_entrypoint(conn: &Connection) -> Result<()> {
let mut statement =
conn.prepare("SELECT workspace_id, created_at FROM workspaces ORDER BY workspace_id")?;
let workspaces = statement
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
drop(statement);
for (workspace_id, created_at) in workspaces {
let existing = crate::config_source::load_state(conn, &workspace_id)?;
let state = match existing {
None => crate::config_source::initial_state()?,
Some(existing) => {
let main =
config_source::VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT)
.map_err(|error| Error::Store(error.to_string()))?;
let snapshot = if existing.snapshot.entries.contains_key(&main) {
existing.snapshot
} else {
existing
.snapshot
.apply(&[config_source::ConfigTreeChange::Create {
path: main.clone(),
content_type: config_source::ConfigContentType::Decodal,
content: crate::config_source::DEFAULT_MAIN_CONFIG_SOURCE.to_string(),
}])
.map_err(|error| Error::Store(error.to_string()))?
};
let contract = config_source::ToolchainContract::new(
config_source::DEFAULT_SCHEMA_VERSION,
vec![main],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
);
let evaluation = config_source::SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract)
.map_err(|diagnostics| {
Error::Store(format!(
"cannot materialize main.dcdl for Workspace {workspace_id}: {}",
serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "config evaluation failed".to_string())
))
})?;
crate::config_source::WorkspaceConfigState {
snapshot,
contract,
projection_digest: evaluation.projection_digest,
}
}
};
conn.execute(
"DELETE FROM workspace_config_tree_revisions WHERE workspace_id = ?1",
[&workspace_id],
)?;
conn.execute(
"DELETE FROM workspace_config_entries WHERE workspace_id = ?1",
[&workspace_id],
)?;
crate::config_source::insert_materialized_state(conn, &workspace_id, &state, &created_at)?;
}
Ok(())
}
pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64) -> Result<()> {
let current = current_schema_version(conn)?; let current = current_schema_version(conn)?;
for migration in MIGRATIONS for migration in MIGRATIONS.iter().filter(|migration| {
.iter() i64::from(migration.version) > current && i64::from(migration.version) <= through_version
.filter(|migration| migration.version > current) }) {
{
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
(migration.apply)(&tx)?; (migration.apply)(&tx)?;
tx.execute( tx.execute(
@@ -4609,6 +4695,10 @@ fn apply_migrations(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
fn apply_migrations(conn: &Connection) -> Result<()> {
apply_migrations_through(conn, i64::MAX)
}
fn align_legacy_bootstrap_schema(conn: &Connection) -> Result<()> { fn align_legacy_bootstrap_schema(conn: &Connection) -> Result<()> {
if table_exists(conn, "repositories")? if table_exists(conn, "repositories")?
&& column_exists(conn, "repositories", "local_root")? && column_exists(conn, "repositories", "local_root")?
@@ -5181,7 +5271,7 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 30); assert_eq!(current_schema_version(&conn).unwrap(), 31);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
} }
@@ -5214,7 +5304,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 30); assert_eq!(current_schema_version(&conn).unwrap(), 31);
assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_sources").unwrap());
assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap());
assert!(!table_exists(&conn, "flow_instances").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap());
@@ -5281,7 +5371,7 @@ INSERT INTO worker_workdir_attachment_reservations (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 30); assert_eq!(current_schema_version(&conn).unwrap(), 31);
let repositories_sql: String = conn let repositories_sql: String = conn
.query_row( .query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
@@ -5461,7 +5551,7 @@ INSERT INTO workdir_registry (
let db = dir.path().join("control-plane.sqlite"); let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap(); let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 29); assert_eq!(store.schema_version().await.unwrap(), 31);
assert!( assert!(
!store !store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) .with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -5478,7 +5568,7 @@ INSERT INTO workdir_registry (
store.upsert_workspace(&record).await.unwrap(); store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 29); assert_eq!(reopened.schema_version().await.unwrap(), 31);
assert_eq!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@@ -6025,7 +6115,7 @@ INSERT INTO workdir_registry (
.unwrap(); .unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 29); assert_eq!(store.schema_version().await.unwrap(), 31);
store store
.with_conn(|conn| { .with_conn(|conn| {
@@ -6214,7 +6304,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn repository_records_round_trip() { async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 29); assert_eq!(store.schema_version().await.unwrap(), 31);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@@ -6280,7 +6370,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() { async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 29); assert_eq!(store.schema_version().await.unwrap(), 31);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@@ -6543,7 +6633,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn account_and_login_records_round_trip() { async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 29); assert_eq!(store.schema_version().await.unwrap(), 31);
let now = "2026-07-22T00:00:00Z".to_string(); let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord { let account = AccountRecord {
account_id: "acct-user-alice".to_string(), account_id: "acct-user-alice".to_string(),
@@ -13,11 +13,13 @@
WorkspaceConfigTreeResponse, WorkspaceConfigTreeResponse,
} from "./types.ts"; } from "./types.ts";
const MAIN_ENTRYPOINT = "main.dcdl";
let { workspaceId }: { workspaceId: string } = $props(); let { workspaceId }: { workspaceId: string } = $props();
let treeState = $state<WorkspaceConfigTreeResponse | null>(null); let treeState = $state<WorkspaceConfigTreeResponse | null>(null);
let selectedPath = $state(""); let selectedPath = $state("");
let source = $state(""); let source = $state("");
let newPath = $state("workspace.dcdl"); let newPath = $state("module.dcdl");
let diagnostics = $state<ConfigDiagnostic[]>([]); let diagnostics = $state<ConfigDiagnostic[]>([]);
let status = $state("Loading source tree…"); let status = $state("Loading source tree…");
let busy = $state(false); let busy = $state(false);
@@ -37,6 +39,7 @@
const selected = $derived( const selected = $derived(
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined, treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
); );
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest); const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
@@ -62,9 +65,7 @@
diagnostics = []; diagnostics = [];
conflict = false; conflict = false;
candidateContract = null; candidateContract = null;
status = treeState.snapshot.revision === 0 status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
? "No committed sources yet. Create workspace.dcdl to begin."
: `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
} catch (error) { } catch (error) {
status = String(error); status = String(error);
} }
@@ -110,14 +111,7 @@
} }
function entrypoints(): string[] { function entrypoints(): string[] {
if (!treeState) return []; return [MAIN_ENTRYPOINT];
const known = new Set(Object.keys(treeState.snapshot.entries));
const configured = treeState.contract.entrypoints.filter((path) => known.has(path));
if (configured.length > 0) return configured;
if (treeState.snapshot.entries["workspace.dcdl"] || selectedPath === "workspace.dcdl") {
return ["workspace.dcdl"];
}
return selectedPath ? [selectedPath] : [];
} }
async function analyze() { async function analyze() {
@@ -296,12 +290,15 @@
type="button" type="button"
class:active={path === selectedPath} class:active={path === selectedPath}
onclick={() => select(path)} onclick={() => select(path)}
>{path}</button> >
<span>{path}</span>
{#if path === MAIN_ENTRYPOINT}<small>entrypoint</small>{/if}
</button>
{/each} {/each}
</nav> </nav>
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}> <form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
<label for="new-config-path">New path</label> <label for="new-config-path">New path</label>
<input id="new-config-path" bind:value={newPath} placeholder="workspace.dcdl" /> <input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" />
<button type="submit">Create draft</button> <button type="submit">Create draft</button>
</form> </form>
</aside> </aside>
@@ -313,13 +310,13 @@
<strong>{selectedPath || "Select or create a source"}</strong> <strong>{selectedPath || "Select or create a source"}</strong>
</div> </div>
<div class="config-source-actions"> <div class="config-source-actions">
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || busy} /> <input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
<button type="button" onclick={renameEntry} disabled={!selected || renamePath === selectedPath || busy}>Rename</button> <button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button> <button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button> <button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button> <button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
<button class="primary" type="button" onclick={commit} disabled={!commitReady || busy}>Commit</button> <button class="primary" type="button" onclick={commit} disabled={!commitReady || busy}>Commit</button>
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || busy}>Delete</button> <button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button>
</div> </div>
</header> </header>
<DecodalSourceEditor <DecodalSourceEditor
@@ -546,6 +546,10 @@
padding: var(--space-2); padding: var(--space-2);
} }
.config-source-tree nav button { .config-source-tree nav button {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
border: 0; border: 0;
border-radius: 0.45rem; border-radius: 0.45rem;
background: transparent; background: transparent;
@@ -556,6 +560,12 @@
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.config-source-tree nav button small {
color: var(--text-muted);
font-size: 0.65rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.config-source-tree nav button.active, .config-source-tree nav button.active,
.config-source-tree nav button:hover { .config-source-tree nav button:hover {
background: var(--interactive-hover); background: var(--interactive-hover);