feat: introduce workspace-scoped repository keys

This commit is contained in:
2026-09-02 16:43:28 +09:00
parent 538da1f2b2
commit c97b3b7b77
8 changed files with 672 additions and 175 deletions
+74 -8
View File
@@ -6,6 +6,54 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const REPOSITORY_KEY_MIN_LEN: usize = 1;
pub const REPOSITORY_KEY_MAX_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepositoryKeyError {
Length,
Character,
LeadingHyphen,
TrailingHyphen,
}
impl std::fmt::Display for RepositoryKeyError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Length => "must contain between 1 and 64 ASCII bytes",
Self::Character => "must contain only lowercase ASCII letters, digits, and hyphens",
Self::LeadingHyphen => "must not start with a hyphen",
Self::TrailingHyphen => "must not end with a hyphen",
})
}
}
impl std::error::Error for RepositoryKeyError {}
/// Validate one immutable Workspace-scoped Repository key.
///
/// Keys are deliberately not normalized: callers must submit the exact canonical
/// lowercase ASCII spelling so idempotency and route identity cannot alias.
pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
let bytes = value.as_bytes();
if !(REPOSITORY_KEY_MIN_LEN..=REPOSITORY_KEY_MAX_LEN).contains(&bytes.len()) {
return Err(RepositoryKeyError::Length);
}
if bytes[0] == b'-' {
return Err(RepositoryKeyError::LeadingHyphen);
}
if bytes[bytes.len() - 1] == b'-' {
return Err(RepositoryKeyError::TrailingHyphen);
}
if !bytes
.iter()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
{
return Err(RepositoryKeyError::Character);
}
Ok(())
}
/// Provider-neutral classification of an authoritative Repository source. /// Provider-neutral classification of an authoritative Repository source.
/// ///
/// Local paths remain distinct from network Git transports so callers cannot /// Local paths remain distinct from network Git transports so callers cannot
@@ -70,8 +118,7 @@ pub struct RepositorySource {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct CreateWorkspaceRepositoryRequest { pub struct CreateWorkspaceRepositoryRequest {
pub repository_id: String, pub repository_key: String,
pub display_name: String,
pub source: String, pub source: String,
#[serde(default)] #[serde(default)]
pub default_ref: Option<String>, pub default_ref: Option<String>,
@@ -80,7 +127,7 @@ pub struct CreateWorkspaceRepositoryRequest {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CreateWorkspaceRepositoryResponse { pub struct CreateWorkspaceRepositoryResponse {
pub workspace_id: String, pub workspace_id: String,
pub repository_id: String, pub repository_key: String,
pub replayed: bool, pub replayed: bool,
} }
@@ -139,8 +186,7 @@ pub struct WorkspaceCatalogListResponse(pub Vec<WorkspaceSummary>);
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryRecord { pub struct WorkspaceRepositoryRecord {
pub workspace_id: String, pub workspace_id: String,
pub repository_id: String, pub repository_key: String,
pub name: String,
pub kind: String, pub kind: String,
pub provider: Option<String>, pub provider: Option<String>,
pub source: RepositorySource, pub source: RepositorySource,
@@ -348,8 +394,7 @@ pub struct GitRepositorySummary {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RepositorySummary { pub struct RepositorySummary {
pub id: String, pub repository_key: String,
pub display_name: String,
pub kind: String, pub kind: String,
pub provider: String, pub provider: String,
pub source: RepositorySource, pub source: RepositorySource,
@@ -410,7 +455,7 @@ pub struct RepositoryDetailResponse {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct RepositoryLogResponse { pub struct RepositoryLogResponse {
pub workspace_id: String, pub workspace_id: String,
pub repository_id: String, pub repository_key: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))] #[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub default_selector: Option<String>, pub default_selector: Option<String>,
@@ -1331,6 +1376,27 @@ mod workdir_typescript_tests {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn repository_key_validation_is_canonical_and_bounded() {
let max = "a".repeat(64);
for valid in ["a", "main", "repo-42", max.as_str()] {
assert_eq!(validate_repository_key(valid), Ok(()), "{valid}");
}
let too_long = "a".repeat(65);
for invalid in [
"",
"-main",
"main-",
"Main",
"main_repo",
"main.repo",
"日本語",
too_long.as_str(),
] {
assert!(validate_repository_key(invalid).is_err(), "{invalid}");
}
}
#[test] #[test]
fn workspace_and_repository_response_shapes_round_trip() { fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({ let workspace = serde_json::json!({
+27 -19
View File
@@ -15,6 +15,7 @@ pub type RepositorySelector = String;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredRepository { pub struct ConfiguredRepository {
pub id: RepositoryId, pub id: RepositoryId,
pub repository_key: String,
pub provider: String, pub provider: String,
pub source: RepositorySource, pub source: RepositorySource,
pub source_revision: u64, pub source_revision: u64,
@@ -22,7 +23,6 @@ pub struct ConfiguredRepository {
pub observed_status: RepositoryObservedStatus, pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>, pub observed_at: Option<String>,
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
pub display_name: Option<String>,
pub default_selector: Option<RepositorySelector>, pub default_selector: Option<RepositorySelector>,
} }
@@ -34,7 +34,7 @@ pub struct RepositoryListProjection {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryLogRead { pub struct RepositoryLogRead {
pub repository_id: RepositoryId, pub repository_key: String,
pub default_selector: Option<RepositorySelector>, pub default_selector: Option<RepositorySelector>,
pub limit: usize, pub limit: usize,
pub commits: Vec<GitCommitSummary>, pub commits: Vec<GitCommitSummary>,
@@ -97,21 +97,28 @@ impl RepositoryRegistryReader {
} }
} }
pub fn summary(&self, id: &str) -> Result<RepositorySummary, RepositoryLookupError> { pub fn summary(
let repository = self &self,
.find(id) repository_key: &str,
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?; ) -> Result<RepositorySummary, RepositoryLookupError> {
let repository = self.find_by_key(repository_key).ok_or_else(|| {
RepositoryLookupError::UnknownRepository {
id: repository_key.to_string(),
}
})?;
Ok(self.summary_for_config(repository)) Ok(self.summary_for_config(repository))
} }
pub fn recent_log( pub fn recent_log(
&self, &self,
id: &str, repository_key: &str,
limit: Option<usize>, limit: Option<usize>,
) -> Result<RepositoryLogRead, RepositoryLookupError> { ) -> Result<RepositoryLogRead, RepositoryLookupError> {
let repository = self let repository = self.find_by_key(repository_key).ok_or_else(|| {
.find(id) RepositoryLookupError::UnknownRepository {
.ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?; id: repository_key.to_string(),
}
})?;
if repository.provider != "git" { if repository.provider != "git" {
return Err(RepositoryLookupError::UnsupportedProvider { return Err(RepositoryLookupError::UnsupportedProvider {
id: repository.id.clone(), id: repository.id.clone(),
@@ -134,7 +141,7 @@ impl RepositoryRegistryReader {
}; };
Ok(RepositoryLogRead { Ok(RepositoryLogRead {
repository_id: repository.id.clone(), repository_key: repository.repository_key.clone(),
default_selector: repository.default_selector.clone(), default_selector: repository.default_selector.clone(),
limit, limit,
commits, commits,
@@ -260,6 +267,12 @@ impl RepositoryRegistryReader {
Ok(repository) Ok(repository)
} }
fn find_by_key(&self, repository_key: &str) -> Option<&ConfiguredRepository> {
self.repositories
.iter()
.find(|repository| repository.repository_key == repository_key)
}
fn find(&self, id: &str) -> Option<&ConfiguredRepository> { fn find(&self, id: &str) -> Option<&ConfiguredRepository> {
self.repositories self.repositories
.iter() .iter()
@@ -267,10 +280,6 @@ impl RepositoryRegistryReader {
} }
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary { fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
let display_name = repository
.display_name
.clone()
.unwrap_or_else(|| repository.id.clone());
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
if repository.source.kind == workspace_api::RepositorySourceKind::Http { if repository.source.kind == workspace_api::RepositorySourceKind::Http {
diagnostics.push(RepositoryDiagnostic { diagnostics.push(RepositoryDiagnostic {
@@ -314,8 +323,7 @@ impl RepositoryRegistryReader {
}; };
RepositorySummary { RepositorySummary {
id: repository.id.clone(), repository_key: repository.repository_key.clone(),
display_name,
kind: repository.provider.clone(), kind: repository.provider.clone(),
provider: repository.provider.clone(), provider: repository.provider.clone(),
source: repository.source.clone(), source: repository.source.clone(),
@@ -600,7 +608,7 @@ mod tests {
}; };
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository { let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
id: "remote".into(), id: "remote".into(),
display_name: Some("Remote".into()), repository_key: "remote".into(),
provider: "git".into(), provider: "git".into(),
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source, source,
@@ -724,7 +732,7 @@ mod tests {
}; };
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository { let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
id: "main".into(), id: "main".into(),
display_name: Some("Main".into()), repository_key: "main".into(),
provider: "git".into(), provider: "git".into(),
source_fingerprint: crate::repository_source::repository_source_fingerprint( source_fingerprint: crate::repository_source::repository_source_fingerprint(
&source_descriptor, &source_descriptor,
@@ -1846,7 +1846,7 @@ mod tests {
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
repository_id: "remote".to_string(), repository_id: "remote".to_string(),
name: "Remote".to_string(), repository_key: "remote".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source, source,
+49 -60
View File
@@ -288,6 +288,7 @@ impl ServerConfig {
.into_iter() .into_iter()
.map(|repository| ConfiguredRepository { .map(|repository| ConfiguredRepository {
id: repository.repository_id, id: repository.repository_id,
repository_key: repository.repository_key,
provider: repository.provider.unwrap_or(repository.kind), provider: repository.provider.unwrap_or(repository.kind),
path: repository_local_path(&repository.source), path: repository_local_path(&repository.source),
source: repository.source, source: repository.source,
@@ -295,7 +296,6 @@ impl ServerConfig {
source_fingerprint: repository.source_fingerprint, source_fingerprint: repository.source_fingerprint,
observed_status: repository.observed_status, observed_status: repository.observed_status,
observed_at: repository.observed_at, observed_at: repository.observed_at,
display_name: Some(repository.name),
default_selector: repository.default_ref, default_selector: repository.default_ref,
}) })
.collect(); .collect();
@@ -1056,8 +1056,7 @@ fn workspace_summary(record: WorkspaceRecord) -> WorkspaceSummary {
fn workspace_repository_record(record: RepositoryRecord) -> WorkspaceRepositoryRecord { fn workspace_repository_record(record: RepositoryRecord) -> WorkspaceRepositoryRecord {
WorkspaceRepositoryRecord { WorkspaceRepositoryRecord {
workspace_id: record.workspace_id, workspace_id: record.workspace_id,
repository_id: record.repository_id, repository_key: record.repository_key,
name: record.name,
kind: record.kind, kind: record.kind,
provider: record.provider, provider: record.provider,
source: record.source, source: record.source,
@@ -2061,13 +2060,14 @@ fn import_configured_repositories(
} }
let now = crate::auth::now_rfc3339(); let now = crate::auth::now_rfc3339();
for repository in &config.repositories { for repository in &config.repositories {
let repository_id = store
.get_repository_by_key(&config.workspace_id, &repository.repository_key)?
.map(|record| record.repository_id)
.unwrap_or_else(|| Uuid::now_v7().to_string());
store.upsert_repository(&RepositoryRecord { store.upsert_repository(&RepositoryRecord {
workspace_id: config.workspace_id.clone(), workspace_id: config.workspace_id.clone(),
repository_id: repository.id.clone(), repository_id,
name: repository repository_key: repository.repository_key.clone(),
.display_name
.clone()
.unwrap_or_else(|| repository.id.clone()),
kind: repository.provider.clone(), kind: repository.provider.clone(),
provider: Some(repository.provider.clone()), provider: Some(repository.provider.clone()),
source: repository.source.clone(), source: repository.source.clone(),
@@ -2102,6 +2102,7 @@ fn configured_repository_from_record(
let path = repository_local_path(&record.source); let path = repository_local_path(&record.source);
Ok(ConfiguredRepository { Ok(ConfiguredRepository {
id: record.repository_id, id: record.repository_id,
repository_key: record.repository_key,
provider, provider,
path, path,
source: record.source, source: record.source,
@@ -2109,7 +2110,6 @@ fn configured_repository_from_record(
source_fingerprint: record.source_fingerprint, source_fingerprint: record.source_fingerprint,
observed_status: record.observed_status, observed_status: record.observed_status,
observed_at: record.observed_at, observed_at: record.observed_at,
display_name: Some(record.name),
default_selector: record.default_ref, default_selector: record.default_ref,
}) })
} }
@@ -7901,8 +7901,9 @@ async fn scoped_create_repository(
) -> ApiResult<(StatusCode, Json<CreateWorkspaceRepositoryResponse>)> { ) -> ApiResult<(StatusCode, Json<CreateWorkspaceRepositoryResponse>)> {
require_workspace_owner(&api, &path.workspace_id, &actor, "ManageRepositories").await?; require_workspace_owner(&api, &path.workspace_id, &actor, "ManageRepositories").await?;
let repository_id = normalize_repository_identifier(&request.repository_id)?; workspace_api::validate_repository_key(&request.repository_key)
let display_name = normalize_repository_text(&request.display_name, "display_name", 256)?; .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
let repository_key = request.repository_key;
let default_ref = request let default_ref = request
.default_ref .default_ref
.as_deref() .as_deref()
@@ -7913,8 +7914,8 @@ async fn scoped_create_repository(
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let record = RepositoryRecord { let record = RepositoryRecord {
workspace_id: path.workspace_id.clone(), workspace_id: path.workspace_id.clone(),
repository_id: repository_id.clone(), repository_id: Uuid::now_v7().to_string(),
name: display_name, repository_key: repository_key.clone(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source_fingerprint: repository_source_fingerprint(&source), source_fingerprint: repository_source_fingerprint(&source),
@@ -7936,7 +7937,7 @@ async fn scoped_create_repository(
} }
RepositoryInsertOutcome::Existing(_) => { RepositoryInsertOutcome::Existing(_) => {
return Err(Error::RepositoryConflict(format!( return Err(Error::RepositoryConflict(format!(
"repository_id {repository_id} already exists with different registration intent" "Repository key {repository_key} already exists with different registration intent"
)) ))
.into()); .into());
} }
@@ -7946,30 +7947,12 @@ async fn scoped_create_repository(
status, status,
Json(CreateWorkspaceRepositoryResponse { Json(CreateWorkspaceRepositoryResponse {
workspace_id: path.workspace_id, workspace_id: path.workspace_id,
repository_id, repository_key,
replayed, replayed,
}), }),
)) ))
} }
fn normalize_repository_identifier(value: &str) -> Result<String> {
let value = value.trim();
if value.is_empty() || value.len() > 128 {
return Err(Error::InvalidInput(
"repository_id must contain 1..=128 characters".to_string(),
));
}
if !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err(Error::InvalidInput(
"repository_id must contain only ASCII letters, digits, '-', '_', or '.'".to_string(),
));
}
Ok(value.to_string())
}
fn normalize_repository_text(value: &str, field: &str, max_len: usize) -> Result<String> { fn normalize_repository_text(value: &str, field: &str, max_len: usize) -> Result<String> {
let value = value.trim(); let value = value.trim();
if value.is_empty() || value.len() > max_len || value.chars().any(char::is_control) { if value.is_empty() || value.len() > max_len || value.chars().any(char::is_control) {
@@ -7985,8 +7968,7 @@ fn repository_create_intent_matches(
requested: &RepositoryRecord, requested: &RepositoryRecord,
) -> bool { ) -> bool {
existing.workspace_id == requested.workspace_id existing.workspace_id == requested.workspace_id
&& existing.repository_id == requested.repository_id && existing.repository_key == requested.repository_key
&& existing.name == requested.name
&& existing.kind == requested.kind && existing.kind == requested.kind
&& existing.provider == requested.provider && existing.provider == requested.provider
&& existing.source == requested.source && existing.source == requested.source
@@ -11643,9 +11625,14 @@ async fn list_repositories(
async fn repository_detail( async fn repository_detail(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>, AxumPath(repository_key): AxumPath<String>,
) -> ApiResult<Json<RepositoryDetailResponse>> { ) -> ApiResult<Json<RepositoryDetailResponse>> {
let item = repository_lookup(api.live_repository_reader()?.summary(&repository_id))?; workspace_api::validate_repository_key(&repository_key).map_err(|error| {
ApiError::from(Error::InvalidInput(format!(
"invalid Repository key: {error}"
)))
})?;
let item = repository_lookup(api.live_repository_reader()?.summary(&repository_key))?;
Ok(Json(RepositoryDetailResponse { Ok(Json(RepositoryDetailResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
item, item,
@@ -11655,22 +11642,27 @@ async fn repository_detail(
async fn repository_log( async fn repository_log(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>, AxumPath(repository_key): AxumPath<String>,
Query(query): Query<LogQuery>, Query(query): Query<LogQuery>,
) -> ApiResult<Json<RepositoryLogResponse>> { ) -> ApiResult<Json<RepositoryLogResponse>> {
workspace_api::validate_repository_key(&repository_key).map_err(|error| {
ApiError::from(Error::InvalidInput(format!(
"invalid Repository key: {error}"
)))
})?;
let RepositoryLogRead { let RepositoryLogRead {
repository_id, repository_key,
default_selector, default_selector,
limit, limit,
commits, commits,
diagnostics, diagnostics,
} = repository_lookup( } = repository_lookup(
api.live_repository_reader()? api.live_repository_reader()?
.recent_log(&repository_id, query.limit), .recent_log(&repository_key, query.limit),
)?; )?;
Ok(Json(RepositoryLogResponse { Ok(Json(RepositoryLogResponse {
workspace_id: api.config.workspace_id, workspace_id: api.config.workspace_id,
repository_id, repository_key,
default_selector, default_selector,
limit, limit,
items: commits, items: commits,
@@ -14381,10 +14373,7 @@ fn working_directory_repository_options(
.iter() .iter()
.map(|repository| WorkingDirectoryRepositoryOption { .map(|repository| WorkingDirectoryRepositoryOption {
id: repository.id.clone(), id: repository.id.clone(),
display_name: repository display_name: repository.repository_key.clone(),
.display_name
.clone()
.unwrap_or_else(|| repository.id.clone()),
default_selector: repository.default_selector.clone(), default_selector: repository.default_selector.clone(),
}) })
.collect() .collect()
@@ -16543,7 +16532,7 @@ mod tests {
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: other_workspace.workspace_id.clone(), workspace_id: other_workspace.workspace_id.clone(),
repository_id: "foreign".to_string(), repository_id: "foreign".to_string(),
name: "Foreign".to_string(), repository_key: "foreign".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source: workspace_api::RepositorySource { source: workspace_api::RepositorySource {
@@ -17942,7 +17931,7 @@ mod tests {
let repositories = vec![RepositoryRecord { let repositories = vec![RepositoryRecord {
workspace_id: "remote-workspace".to_string(), workspace_id: "remote-workspace".to_string(),
repository_id: "main".to_string(), repository_id: "main".to_string(),
name: "Main".to_string(), repository_key: "main".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
@@ -17982,6 +17971,7 @@ mod tests {
}; };
config.repositories = vec![ConfiguredRepository { config.repositories = vec![ConfiguredRepository {
id: TEST_REPOSITORY_ID.to_string(), id: TEST_REPOSITORY_ID.to_string(),
repository_key: "test-repository".to_string(),
provider: "git".to_string(), provider: "git".to_string(),
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source, source,
@@ -17989,7 +17979,6 @@ mod tests {
observed_status: workspace_api::RepositoryObservedStatus::Unverified, observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None, observed_at: None,
path: Some(workspace_root), path: Some(workspace_root),
display_name: Some("Test Repository".to_string()),
default_selector: Some("HEAD".to_string()), default_selector: Some("HEAD".to_string()),
}]; }];
config config
@@ -18135,8 +18124,8 @@ mod tests {
operation_key: "create-auth".to_owned(), operation_key: "create-auth".to_owned(),
display_name: "Auth Workspace".to_owned(), display_name: "Auth Workspace".to_owned(),
repository: crate::workspace_catalog::InitialRepositoryIntent { repository: crate::workspace_catalog::InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: repository.display().to_string(), uri: repository.display().to_string(),
display_name: None,
default_ref: None, default_ref: None,
}, },
}, },
@@ -18262,8 +18251,8 @@ mod tests {
operation_key: "create-authenticated-workspace".to_owned(), operation_key: "create-authenticated-workspace".to_owned(),
display_name: "Authenticated Workspace".to_owned(), display_name: "Authenticated Workspace".to_owned(),
repository: crate::workspace_catalog::InitialRepositoryIntent { repository: crate::workspace_catalog::InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: created_repository.display().to_string(), uri: created_repository.display().to_string(),
display_name: Some("Main".to_owned()),
default_ref: Some("develop".to_owned()), default_ref: Some("develop".to_owned()),
}, },
}; };
@@ -18415,8 +18404,7 @@ mod tests {
let repositories_uri = format!("/api/w/{}/repositories", workspace.workspace.workspace_id); let repositories_uri = format!("/api/w/{}/repositories", workspace.workspace.workspace_id);
let repository_request = serde_json::json!({ let repository_request = serde_json::json!({
"repository_id": "documentation", "repository_key": "documentation",
"display_name": "Documentation",
"source": temp.path().join("documentation").display().to_string(), "source": temp.path().join("documentation").display().to_string(),
"default_ref": "main" "default_ref": "main"
}); });
@@ -18730,8 +18718,8 @@ mod tests {
operation_key: "create-a".to_string(), operation_key: "create-a".to_string(),
display_name: "Workspace A".to_string(), display_name: "Workspace A".to_string(),
repository: crate::workspace_catalog::InitialRepositoryIntent { repository: crate::workspace_catalog::InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: repository_a.display().to_string(), uri: repository_a.display().to_string(),
display_name: None,
default_ref: None, default_ref: None,
}, },
}, },
@@ -18744,8 +18732,8 @@ mod tests {
operation_key: "create-b".to_string(), operation_key: "create-b".to_string(),
display_name: "Workspace B".to_string(), display_name: "Workspace B".to_string(),
repository: crate::workspace_catalog::InitialRepositoryIntent { repository: crate::workspace_catalog::InitialRepositoryIntent {
repository_key: "main".to_string(),
uri: repository_b.display().to_string(), uri: repository_b.display().to_string(),
display_name: None,
default_ref: None, default_ref: None,
}, },
}, },
@@ -22217,7 +22205,7 @@ mod tests {
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
repository_id: repository_id.to_string(), repository_id: repository_id.to_string(),
name: repository_id.to_string(), repository_key: repository_id.to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source: workspace_api::RepositorySource { source: workspace_api::RepositorySource {
@@ -24987,9 +24975,7 @@ mod tests {
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
repository_id: configured_repository.id, repository_id: configured_repository.id,
name: configured_repository repository_key: configured_repository.repository_key,
.display_name
.unwrap_or_else(|| "Test Repository".to_string()),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some(configured_repository.provider), provider: Some(configured_repository.provider),
source: configured_repository.source, source: configured_repository.source,
@@ -25289,7 +25275,10 @@ mod tests {
let repositories = get_json(app.clone(), "/api/repositories").await; let repositories = get_json(app.clone(), "/api/repositories").await;
let typed_repositories: workspace_api::RepositoryListResponse = let typed_repositories: workspace_api::RepositoryListResponse =
serde_json::from_value(repositories.clone()).unwrap(); serde_json::from_value(repositories.clone()).unwrap();
assert_eq!(typed_repositories.items[0].id, TEST_REPOSITORY_ID); assert_eq!(
typed_repositories.items[0].repository_key,
TEST_REPOSITORY_ID
);
assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID); assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID);
assert_eq!(repositories["items"][0]["kind"], "git"); assert_eq!(repositories["items"][0]["kind"], "git");
assert_eq!( assert_eq!(
@@ -25759,6 +25748,7 @@ mod tests {
let mut config = test_server_config(root.path()); let mut config = test_server_config(root.path());
config.repositories = vec![ConfiguredRepository { config.repositories = vec![ConfiguredRepository {
id: "files".to_string(), id: "files".to_string(),
repository_key: "files".to_string(),
provider: "local_fs".to_string(), provider: "local_fs".to_string(),
source: workspace_api::RepositorySource { source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath, kind: workspace_api::RepositorySourceKind::LocalPath,
@@ -25769,7 +25759,6 @@ mod tests {
observed_status: workspace_api::RepositoryObservedStatus::Unverified, observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None, observed_at: None,
path: Some(root.path().to_path_buf()), path: Some(root.path().to_path_buf()),
display_name: None,
default_selector: None, default_selector: None,
}]; }];
let store = test_control_store(&config); let store = test_control_store(&config);
+504 -62
View File
@@ -272,6 +272,11 @@ const MIGRATIONS: &[Migration] = &[
name: "create durable Workdir removal operations", name: "create durable Workdir removal operations",
apply: crate::workdir_removal::create_workdir_removal_operations, apply: crate::workdir_removal::create_workdir_removal_operations,
}, },
Migration {
version: 50,
name: "replace public Repository ids with immutable Workspace keys",
apply: migrate_repository_identity_to_keys,
},
]; ];
struct Migration { struct Migration {
@@ -323,7 +328,7 @@ pub struct WorkerCreateReservation {
pub struct RepositoryRecord { pub struct RepositoryRecord {
pub workspace_id: String, pub workspace_id: String,
pub repository_id: String, pub repository_id: String,
pub name: String, pub repository_key: String,
pub kind: String, pub kind: String,
pub provider: Option<String>, pub provider: Option<String>,
pub source: RepositorySource, pub source: RepositorySource,
@@ -879,6 +884,16 @@ pub trait ControlPlaneStore: Send + Sync {
workspace_id: &str, workspace_id: &str,
repository_id: &str, repository_id: &str,
) -> Result<Option<RepositoryRecord>>; ) -> Result<Option<RepositoryRecord>>;
fn get_repository_by_key(
&self,
workspace_id: &str,
repository_key: &str,
) -> Result<Option<RepositoryRecord>> {
Ok(self
.list_repositories(workspace_id)?
.into_iter()
.find(|repository| repository.repository_key == repository_key))
}
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>; fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>>;
fn put_flow_source_for_kind( fn put_flow_source_for_kind(
@@ -1914,11 +1929,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
read_workspace_record, read_workspace_record,
)?; )?;
let repository = tx.query_row( let repository = tx.query_row(
r#"SELECT workspace_id, repository_id, name, kind, provider, r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref, source_revision, source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#, FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![workspace.workspace_id, record.repository.repository_id], params![workspace.workspace_id, record.repository.repository_key],
read_repository_record, read_repository_record,
)?; )?;
let config_revision = crate::config_source::load_state(&tx, &workspace.workspace_id)? let config_revision = crate::config_source::load_state(&tx, &workspace.workspace_id)?
@@ -1953,15 +1968,19 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
} }
let existing_repository = tx let existing_repository = tx
.query_row( .query_row(
r#"SELECT workspace_id, repository_id, name, kind, provider, r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref, source_revision, source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#, FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![record.repository.workspace_id, record.repository.repository_id], params![record.repository.workspace_id, record.repository.repository_key],
read_repository_record, read_repository_record,
) )
.optional()?; .optional()?;
if existing_repository.as_ref() != Some(&record.repository) { if existing_repository.as_ref().is_none_or(|existing| {
let mut requested = record.repository.clone();
requested.repository_id.clone_from(&existing.repository_id);
existing != &requested
}) {
return Err(Error::WorkspaceConfigConflict( return Err(Error::WorkspaceConfigConflict(
"Workspace initial repository already exists with different metadata" "Workspace initial repository already exists with different metadata"
.to_string(), .to_string(),
@@ -1993,14 +2012,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
)?; )?;
tx.execute( tx.execute(
r#"INSERT INTO repositories ( r#"INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri, workspace_id, repository_id, repository_key, kind, provider, uri,
source_kind, source_uri, default_ref, source_revision, source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at source_fingerprint, observed_status, observed_at, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
params![ params![
record.repository.workspace_id, record.repository.workspace_id,
record.repository.repository_id, record.repository.repository_id,
record.repository.name, record.repository.repository_key,
record.repository.kind, record.repository.kind,
record.repository.provider, record.repository.provider,
record.repository.source.uri, record.repository.source.uri,
@@ -2191,15 +2210,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
} }
fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()> { fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()> {
validate_repository_record_identity(record)?;
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
r#"INSERT INTO repositories ( r#"INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri, workspace_id, repository_id, repository_key, kind, provider, uri,
source_kind, source_uri, default_ref, source_revision, source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at source_fingerprint, observed_status, observed_at, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
ON CONFLICT(workspace_id, repository_id) DO UPDATE SET ON CONFLICT(workspace_id, repository_key) DO UPDATE SET
name = excluded.name,
kind = excluded.kind, kind = excluded.kind,
provider = excluded.provider, provider = excluded.provider,
default_ref = excluded.default_ref, default_ref = excluded.default_ref,
@@ -2209,7 +2228,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
params![ params![
record.workspace_id, record.workspace_id,
record.repository_id, record.repository_id,
record.name, record.repository_key,
record.kind, record.kind,
record.provider, record.provider,
record.source.uri, record.source.uri,
@@ -2229,16 +2248,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
} }
fn insert_repository(&self, record: &RepositoryRecord) -> Result<RepositoryInsertOutcome> { fn insert_repository(&self, record: &RepositoryRecord) -> Result<RepositoryInsertOutcome> {
validate_repository_record_identity(record)?;
self.with_conn_mut(|conn| { self.with_conn_mut(|conn| {
let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let existing = transaction let existing = transaction
.query_row( .query_row(
r#"SELECT workspace_id, repository_id, name, kind, provider, r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref, source_revision, source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories FROM repositories
WHERE workspace_id = ?1 AND repository_id = ?2"#, WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![record.workspace_id, record.repository_id], params![record.workspace_id, record.repository_key],
read_repository_record, read_repository_record,
) )
.optional()?; .optional()?;
@@ -2249,14 +2269,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
transaction.execute( transaction.execute(
r#"INSERT INTO repositories ( r#"INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri, workspace_id, repository_id, repository_key, kind, provider, uri,
source_kind, source_uri, default_ref, source_revision, source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at source_fingerprint, observed_status, observed_at, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
params![ params![
record.workspace_id, record.workspace_id,
record.repository_id, record.repository_id,
record.name, record.repository_key,
record.kind, record.kind,
record.provider, record.provider,
record.source.uri, record.source.uri,
@@ -2283,7 +2303,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Option<RepositoryRecord>> { ) -> Result<Option<RepositoryRecord>> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.query_row( conn.query_row(
r#"SELECT workspace_id, repository_id, name, kind, provider, r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref, source_revision, source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories FROM repositories
@@ -2296,15 +2316,35 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn get_repository_by_key(
&self,
workspace_id: &str,
repository_key: &str,
) -> Result<Option<RepositoryRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories
WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![workspace_id, repository_key],
read_repository_record,
)
.optional()
.map_err(Error::from)
})
}
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> { fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
self.with_conn(|conn| { self.with_conn(|conn| {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
r#"SELECT workspace_id, repository_id, name, kind, provider, r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref, source_revision, source_kind, source_uri, default_ref, source_revision,
source_fingerprint, observed_status, observed_at, created_at, updated_at source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories FROM repositories
WHERE workspace_id = ?1 WHERE workspace_id = ?1
ORDER BY repository_id ASC"#, ORDER BY repository_key ASC"#,
)?; )?;
let rows = stmt.query_map(params![workspace_id], read_repository_record)?; let rows = stmt.query_map(params![workspace_id], read_repository_record)?;
rows.collect::<std::result::Result<Vec<_>, _>>() rows.collect::<std::result::Result<Vec<_>, _>>()
@@ -5332,6 +5372,12 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkspaceR
}) })
} }
fn validate_repository_record_identity(record: &RepositoryRecord) -> Result<()> {
workspace_api::validate_repository_key(&record.repository_key)
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
Ok(())
}
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> { fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
let source_kind_value = row.get::<_, String>(5)?; let source_kind_value = row.get::<_, String>(5)?;
let source_kind = workspace_api::RepositorySourceKind::parse(&source_kind_value) let source_kind = workspace_api::RepositorySourceKind::parse(&source_kind_value)
@@ -5354,7 +5400,7 @@ fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<Repositor
Ok(RepositoryRecord { Ok(RepositoryRecord {
workspace_id: row.get(0)?, workspace_id: row.get(0)?,
repository_id: row.get(1)?, repository_id: row.get(1)?,
name: row.get(2)?, repository_key: row.get(2)?,
kind: row.get(3)?, kind: row.get(3)?,
provider: row.get(4)?, provider: row.get(4)?,
source, source,
@@ -7012,6 +7058,179 @@ fn normalize_schema_sql(sql: &str) -> String {
.to_ascii_lowercase() .to_ascii_lowercase()
} }
fn migrate_repository_identity_to_keys(conn: &Connection) -> Result<()> {
const REPOSITORY_REFERENCE_TABLES: &[&str] = &[
"artifacts",
"merge_requests",
"typed_tickets",
"workdir_create_operations",
"workdir_registry",
"workdir_removal_operations",
];
// Read-only preflight every legacy public id and every persisted relational
// reference before creating the mapping or mutating authority.
let legacy_repositories = {
let mut stmt = conn.prepare(
"SELECT workspace_id, repository_id FROM repositories ORDER BY workspace_id, repository_id",
)?;
stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?
};
for (workspace_id, repository_key) in &legacy_repositories {
workspace_api::validate_repository_key(repository_key).map_err(|error| {
Error::Store(format!(
"repository identity migration rejected {workspace_id}/{repository_key:?}: {error}"
))
})?;
}
let tables = {
let mut stmt = conn.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)?;
stmt.query_map([], |row| row.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?
};
for table in &tables {
let quoted = table.replace('"', "\"\"");
let pragma = format!("PRAGMA table_info(\"{quoted}\")");
let mut stmt = conn.prepare(&pragma)?;
let has_repository_id = stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<std::result::Result<Vec<_>, _>>()?
.iter()
.any(|column| column == "repository_id");
if has_repository_id
&& table != "repositories"
&& table != "legacy_repositories"
&& !REPOSITORY_REFERENCE_TABLES.contains(&table.as_str())
{
return Err(Error::Store(format!(
"repository identity migration does not recognize repository_id authority in table {table}"
)));
}
}
for table in REPOSITORY_REFERENCE_TABLES {
if !tables.iter().any(|candidate| candidate == table) {
continue;
}
let sql = format!(
r#"SELECT COUNT(*)
FROM "{table}" AS child
LEFT JOIN repositories AS repository
ON repository.workspace_id = child.workspace_id
AND repository.repository_id = child.repository_id
WHERE child.repository_id IS NOT NULL
AND repository.repository_id IS NULL"#
);
let dangling: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
if dangling != 0 {
return Err(Error::Store(format!(
"repository identity migration found {dangling} dangling same-Workspace reference(s) in {table}"
)));
}
}
conn.execute_batch(
r#"
CREATE TEMP TABLE repository_identity_v50 (
workspace_id TEXT NOT NULL,
old_repository_id TEXT NOT NULL,
new_repository_id TEXT NOT NULL,
PRIMARY KEY (workspace_id, old_repository_id),
UNIQUE (new_repository_id)
) WITHOUT ROWID;
"#,
)?;
for (workspace_id, old_repository_id) in &legacy_repositories {
conn.execute(
r#"INSERT INTO repository_identity_v50 (
workspace_id, old_repository_id, new_repository_id
) VALUES (?1, ?2, ?3)"#,
params![workspace_id, old_repository_id, Uuid::now_v7().to_string()],
)?;
}
for table in REPOSITORY_REFERENCE_TABLES {
if !tables.iter().any(|candidate| candidate == table) {
continue;
}
let sql = format!(
r#"UPDATE "{table}" AS child
SET repository_id = (
SELECT mapping.new_repository_id
FROM repository_identity_v50 AS mapping
WHERE mapping.workspace_id = child.workspace_id
AND mapping.old_repository_id = child.repository_id
)
WHERE child.repository_id IS NOT NULL"#
);
conn.execute(&sql, [])?;
}
conn.execute_batch(
r#"
CREATE TABLE repositories_v50 (
workspace_id TEXT NOT NULL,
repository_id TEXT NOT NULL PRIMARY KEY,
repository_key TEXT NOT NULL
CHECK(length(repository_key) BETWEEN 1 AND 64)
CHECK(repository_key NOT GLOB '*[^a-z0-9-]*')
CHECK(substr(repository_key, 1, 1) <> '-')
CHECK(substr(repository_key, -1, 1) <> '-'),
kind TEXT NOT NULL,
provider TEXT,
uri TEXT NOT NULL,
default_ref TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_uri TEXT NOT NULL,
source_revision INTEGER NOT NULL DEFAULT 1,
source_fingerprint TEXT NOT NULL,
observed_status TEXT NOT NULL DEFAULT 'unverified',
observed_at TEXT,
UNIQUE(workspace_id, repository_key),
UNIQUE(workspace_id, repository_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
INSERT INTO repositories_v50 (
workspace_id, repository_id, repository_key, kind, provider, uri,
default_ref, created_at, updated_at, source_kind, source_uri,
source_revision, source_fingerprint, observed_status, observed_at
)
SELECT repository.workspace_id,
mapping.new_repository_id,
repository.repository_id,
repository.kind,
repository.provider,
repository.uri,
repository.default_ref,
repository.created_at,
repository.updated_at,
repository.source_kind,
repository.source_uri,
repository.source_revision,
repository.source_fingerprint,
repository.observed_status,
repository.observed_at
FROM repositories AS repository
JOIN repository_identity_v50 AS mapping
ON mapping.workspace_id = repository.workspace_id
AND mapping.old_repository_id = repository.repository_id;
DROP TABLE repositories;
ALTER TABLE repositories_v50 RENAME TO repositories;
CREATE INDEX repositories_workspace_provider_idx
ON repositories(workspace_id, provider);
DROP TABLE repository_identity_v50;
"#,
)?;
Ok(())
}
fn require_workspace_account_owner(conn: &Connection) -> Result<()> { fn require_workspace_account_owner(conn: &Connection) -> Result<()> {
let actual_columns = { let actual_columns = {
let mut statement = conn.prepare("PRAGMA table_info(workspaces)")?; let mut statement = conn.prepare("PRAGMA table_info(workspaces)")?;
@@ -9523,6 +9742,62 @@ pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64)
continue; continue;
} }
if migration.version == 50 {
conn.execute_batch(
"PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON; BEGIN EXCLUSIVE;",
)?;
let result = (|| -> Result<()> {
(migration.apply)(conn)?;
let dangling_reference: Option<(String, String)> = conn
.query_row(
"SELECT name, sql FROM sqlite_schema \
WHERE sql LIKE '%repositories_v50%' \
OR sql LIKE '%repository_identity_v50%' LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
if let Some((object, sql)) = dangling_reference {
return Err(Error::Store(format!(
"migration 50 left a temporary Repository reference in `{object}`: {sql}"
)));
}
let foreign_key_failures: i64 =
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})?;
if foreign_key_failures != 0 {
return Err(Error::Store(format!(
"migration 50 found {foreign_key_failures} foreign key violation(s)"
)));
}
conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![migration.version, migration.name],
)?;
conn.execute_batch("COMMIT;")?;
Ok(())
})();
if result.is_err() && !conn.is_autocommit() {
conn.execute_batch("ROLLBACK;")?;
}
conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;")
.map_err(|error| {
Error::Store(format!(
"migration 50 could not restore FK enforcement: {error}"
))
})?;
let foreign_keys_enabled =
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?;
if foreign_keys_enabled != 1 {
return Err(Error::Store(
"migration 50 did not restore foreign key enforcement".to_string(),
));
}
result?;
continue;
}
if migration.version == 48 { if migration.version == 48 {
// Rebuilding the parent Workspace table requires FK enforcement to be disabled // Rebuilding the parent Workspace table requires FK enforcement to be disabled
// outside the transaction. The migration, verification, and schema marker still // outside the transaction. The migration, verification, and schema marker still
@@ -10150,7 +10425,7 @@ mod tests {
let store = SqliteWorkspaceStore::open(&path).unwrap(); let store = SqliteWorkspaceStore::open(&path).unwrap();
store store
.with_conn(|conn| { .with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 49); assert_eq!(current_schema_version(conn)?, 50);
assert!(table_exists(conn, "workdir_removal_operations")?); assert!(table_exists(conn, "workdir_removal_operations")?);
let columns = table_columns(conn, "workdir_removal_operations")?; let columns = table_columns(conn, "workdir_removal_operations")?;
for required in [ for required in [
@@ -10180,7 +10455,12 @@ mod tests {
); );
} }
let preserved: (String, String, String) = conn.query_row( let preserved: (String, String, String) = conn.query_row(
"SELECT workspace_id, repository_id, materialization_status FROM workdir_registry WHERE workdir_id='workdir-a'", "SELECT workdir.workspace_id, repository.repository_key, workdir.materialization_status \
FROM workdir_registry AS workdir \
JOIN repositories AS repository \
ON repository.workspace_id = workdir.workspace_id \
AND repository.repository_id = workdir.repository_id \
WHERE workdir.workdir_id='workdir-a'",
[], [],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?; )?;
@@ -10250,11 +10530,11 @@ mod tests {
assign_explicit_test_workspace_owner(&conn); assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
let remote = conn let remote = conn
.query_row( .query_row(
"SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \ "SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \
FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'remote'", FROM repositories WHERE workspace_id = 'workspace-a' AND repository_key = 'remote'",
[], [],
|row| { |row| {
Ok(( Ok((
@@ -10276,7 +10556,7 @@ mod tests {
let invalid = conn let invalid = conn
.query_row( .query_row(
"SELECT source_kind, observed_status FROM repositories \ "SELECT source_kind, observed_status FROM repositories \
WHERE workspace_id = 'workspace-a' AND repository_id = 'invalid'", WHERE workspace_id = 'workspace-a' AND repository_key = 'invalid'",
[], [],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
) )
@@ -10329,7 +10609,7 @@ mod tests {
let before = std::fs::read(&path).unwrap(); let before = std::fs::read(&path).unwrap();
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 36); assert_eq!(plan.current_schema_version, 36);
assert_eq!(plan.target_schema_version, 49); assert_eq!(plan.target_schema_version, 50);
assert!(plan.migration_required); assert!(plan.migration_required);
assert_eq!(plan.worker_count, 1); assert_eq!(plan.worker_count, 1);
assert_eq!(plan.mappings[0].legacy_worker_id, 7); assert_eq!(plan.mappings[0].legacy_worker_id, 7);
@@ -10343,7 +10623,7 @@ mod tests {
store store
.with_conn(|conn| { .with_conn(|conn| {
assert!(table_exists(conn, "worker_diagnostics_archives")?); assert!(table_exists(conn, "worker_diagnostics_archives")?);
assert_eq!(current_schema_version(conn)?, 49); assert_eq!(current_schema_version(conn)?, 50);
Ok(()) Ok(())
}) })
.unwrap(); .unwrap();
@@ -10483,7 +10763,7 @@ mod tests {
), ),
] ]
); );
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
let foreign_key_error: Option<String> = conn let foreign_key_error: Option<String> = conn
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
.optional() .optional()
@@ -10613,7 +10893,7 @@ INSERT INTO worker_orphan_diagnostics (
assign_explicit_test_workspace_owner(&conn); assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
let controller_worker_id: String = conn let controller_worker_id: String = conn
.query_row( .query_row(
@@ -10731,7 +11011,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
} }
@@ -10750,7 +11030,7 @@ INSERT INTO worker_orphan_diagnostics (
assign_explicit_test_workspace_owner(&conn); assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
let settings = conn let settings = conn
.query_row( .query_row(
"SELECT settings_revision, language FROM workspace_memory_settings \ "SELECT settings_revision, language FROM workspace_memory_settings \
@@ -10791,7 +11071,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(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
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());
@@ -10859,7 +11139,7 @@ INSERT INTO worker_workdir_attachment_reservations (
assign_explicit_test_workspace_owner(&conn); assign_explicit_test_workspace_owner(&conn);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
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'",
@@ -10867,7 +11147,8 @@ INSERT INTO worker_workdir_attachment_reservations (
|row| row.get(0), |row| row.get(0),
) )
.unwrap(); .unwrap();
assert!(repositories_sql.contains("PRIMARY KEY (workspace_id, repository_id)")); assert!(repositories_sql.contains("repository_id TEXT NOT NULL PRIMARY KEY"));
assert!(repositories_sql.contains("UNIQUE(workspace_id, repository_key)"));
let preserved: (i64, i64, i64) = ( let preserved: (i64, i64, i64) = (
conn.query_row("SELECT COUNT(*) FROM artifacts", [], |row| row.get(0)) conn.query_row("SELECT COUNT(*) FROM artifacts", [], |row| row.get(0))
.unwrap(), .unwrap(),
@@ -10891,14 +11172,20 @@ INSERT INTO worker_workdir_attachment_reservations (
assert_eq!(foreign_key_violations, 0); assert_eq!(foreign_key_violations, 0);
conn.execute( conn.execute(
r#"INSERT INTO repositories ( r#"INSERT INTO repositories (
workspace_id, repository_id, name, kind, uri, created_at, updated_at workspace_id, repository_id, repository_key, kind, provider, uri, default_ref,
) VALUES ('workspace-b', 'main', 'Other Main', 'git', '/repo-b', '2', '2')"#, created_at, updated_at, source_kind, source_uri, source_revision,
source_fingerprint, observed_status
) VALUES (
'workspace-b', '01890f47-3c22-7cc0-98c4-dc0c0c07398f', 'main',
'git', 'local', '/repo-b', 'HEAD', '2', '2', 'local', '/repo-b', 1,
'sha256:test', 'unverified'
)"#,
[], [],
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
conn.query_row( conn.query_row(
"SELECT COUNT(*) FROM repositories WHERE repository_id = 'main'", "SELECT COUNT(*) FROM repositories WHERE repository_key = 'main'",
[], [],
|row| row.get::<_, i64>(0), |row| row.get::<_, i64>(0),
) )
@@ -11001,7 +11288,7 @@ INSERT INTO workdir_registry (
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
repository_id: "main".to_string(), repository_id: "main".to_string(),
name: "Main".to_string(), repository_key: "main".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source: RepositorySource { source: RepositorySource {
@@ -11042,7 +11329,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(), 49); assert_eq!(store.schema_version().await.unwrap(), 50);
assert!( assert!(
!store !store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) .with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -11059,7 +11346,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(), 49); assert_eq!(reopened.schema_version().await.unwrap(), 50);
assert_eq!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@@ -11825,7 +12112,7 @@ INSERT INTO worker_registry (
let migrated = SqliteWorkspaceStore::open(&db_path).unwrap(); let migrated = SqliteWorkspaceStore::open(&db_path).unwrap();
migrated migrated
.with_conn(|conn| { .with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 49); assert_eq!(current_schema_version(conn)?, 50);
assert_eq!( assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?, conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?,
1, 1,
@@ -11912,7 +12199,7 @@ INSERT INTO worker_registry (
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: "workspace-role".to_string(), workspace_id: "workspace-role".to_string(),
repository_id: "main".to_string(), repository_id: "main".to_string(),
name: "Main".to_string(), repository_key: "main".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source: RepositorySource { source: RepositorySource {
@@ -12176,7 +12463,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 44); assert_eq!(current_schema_version(&conn).unwrap(), 44);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
assert!(table_exists(&conn, "workdir_create_operations").unwrap()); assert!(table_exists(&conn, "workdir_create_operations").unwrap());
let columns = table_columns(&conn, "workdir_create_operations").unwrap(); let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [ for required in [
@@ -12203,7 +12490,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 45); assert_eq!(current_schema_version(&conn).unwrap(), 45);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
for table in [ for table in [
"repository_ssh_credentials", "repository_ssh_credentials",
"repository_ssh_credential_revisions", "repository_ssh_credential_revisions",
@@ -12230,7 +12517,7 @@ INSERT INTO worker_registry (
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 46);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
let columns = table_columns(&conn, "workdir_create_operations").unwrap(); let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [ for required in [
"source_kind", "source_kind",
@@ -12258,19 +12545,173 @@ INSERT INTO worker_registry (
} }
} }
#[test]
fn schema_v50_rekeys_repositories_and_same_workspace_references() {
let conn = workspace_owner_schema_47();
apply_migrations_through(&conn, 49).unwrap();
conn.execute_batch(
r#"
INSERT INTO accounts (
account_id, kind, handle, display_name, created_at, updated_at
) VALUES ('owner-account', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces (
workspace_id, display_name, state, created_at, updated_at, owner_account_id
) VALUES
('workspace-a', 'Workspace A', 'active', '1', '1', 'owner-account'),
('workspace-b', 'Workspace B', 'active', '1', '1', 'owner-account');
INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri, default_ref,
created_at, updated_at, source_kind, source_uri, source_revision,
source_fingerprint, observed_status
) VALUES
('workspace-a', 'main', 'Legacy A', 'git', 'git', '/repo-a', 'develop',
'1', '1', 'local_path', '/repo-a', 1, 'sha256:a', 'unverified'),
('workspace-b', 'main', 'Legacy B', 'git', 'git', '/repo-b', 'develop',
'1', '1', 'local_path', '/repo-b', 1, 'sha256:b', 'unverified');
INSERT INTO artifacts (
workspace_id, artifact_id, kind, uri, created_at,
created_by_kind, created_by_key, created_by_display, repository_id
) VALUES
('workspace-a', 'artifact-a', 'report', 'artifact://a', '1',
'worker', 'W-1', 'Worker 1', 'main'),
('workspace-b', 'artifact-b', 'report', 'artifact://b', '1',
'worker', 'W-2', 'Worker 2', 'main');
INSERT INTO workdir_registry (
workspace_id, workdir_id, runtime_id, repository_id,
materialization_status, cleanliness, created_at, updated_at
) VALUES
('workspace-a', 'workdir-a', 'runtime-a', 'main', 'present', 'clean', '1', '1'),
('workspace-b', 'workdir-b', 'runtime-b', 'main', 'present', 'clean', '1', '1');
"#,
)
.unwrap();
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 50);
let repositories = {
let mut stmt = conn
.prepare(
"SELECT workspace_id, repository_id, repository_key FROM repositories ORDER BY workspace_id",
)
.unwrap();
stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
};
assert_eq!(repositories.len(), 2);
assert_eq!(repositories[0].2, "main");
assert_eq!(repositories[1].2, "main");
assert_ne!(repositories[0].1, repositories[1].1);
for (_, repository_id, _) in &repositories {
assert_eq!(Uuid::parse_str(repository_id).unwrap().get_version_num(), 7);
}
for (workspace_id, repository_id, _) in &repositories {
let artifact_repository_id: String = conn
.query_row(
"SELECT repository_id FROM artifacts WHERE workspace_id = ?1",
params![workspace_id],
|row| row.get(0),
)
.unwrap();
let workdir_repository_id: String = conn
.query_row(
"SELECT repository_id FROM workdir_registry WHERE workspace_id = ?1",
params![workspace_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(&artifact_repository_id, repository_id);
assert_eq!(&workdir_repository_id, repository_id);
}
assert!(
table_columns(&conn, "repositories")
.unwrap()
.iter()
.all(|column| column != "name")
);
let foreign_key_failures: i64 = conn
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(foreign_key_failures, 0);
}
#[test]
fn schema_v50_preflight_rolls_back_invalid_legacy_repository_key() {
let conn = workspace_owner_schema_47();
apply_migrations_through(&conn, 49).unwrap();
conn.execute_batch(
r#"
INSERT INTO accounts (
account_id, kind, handle, display_name, created_at, updated_at
) VALUES ('owner-account', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces (
workspace_id, display_name, state, created_at, updated_at, owner_account_id
) VALUES ('workspace-a', 'Workspace A', 'active', '1', '1', 'owner-account');
INSERT INTO repositories (
workspace_id, repository_id, name, kind, provider, uri, default_ref,
created_at, updated_at, source_kind, source_uri, source_revision,
source_fingerprint, observed_status
) VALUES (
'workspace-a', 'Invalid_Key', 'Legacy', 'git', 'git', '/repo', 'develop',
'1', '1', 'local_path', '/repo', 1, 'sha256:a', 'unverified'
);
"#,
)
.unwrap();
let schema_before: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'repositories'",
[],
|row| row.get(0),
)
.unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string();
assert!(error.contains("Invalid_Key"), "{error}");
assert!(error.contains("lowercase ASCII"), "{error}");
assert_eq!(current_schema_version(&conn).unwrap(), 49);
let schema_after: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'repositories'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(schema_after, schema_before);
let persisted: (String, String) = conn
.query_row(
"SELECT repository_id, name FROM repositories WHERE workspace_id = 'workspace-a'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(persisted, ("Invalid_Key".to_string(), "Legacy".to_string()));
}
#[test] #[test]
fn server_refuses_a_database_from_a_newer_schema_generation() { fn server_refuses_a_database_from_a_newer_schema_generation() {
let conn = Connection::open_in_memory().unwrap(); let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap(); configure_sqlite(&conn).unwrap();
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
conn.execute( conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (50, 'future')", "INSERT INTO __yoi_schema_migrations (version, name) VALUES (51, 'future')",
[], [],
) )
.unwrap(); .unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string(); let error = apply_migrations(&conn).unwrap_err().to_string();
assert!(error.contains("schema version 50 is newer"), "{error}"); assert!(error.contains("schema version 51 is newer"), "{error}");
assert!(error.contains("refusing to serve"), "{error}"); assert!(error.contains("refusing to serve"), "{error}");
} }
@@ -12494,7 +12935,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-
assign_explicit_test_workspace_owner(&conn); assign_explicit_test_workspace_owner(&conn);
apply_migrations(&mut conn).unwrap(); apply_migrations(&mut conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 49); assert_eq!(current_schema_version(&conn).unwrap(), 50);
let workspace_id: Option<String> = conn let workspace_id: Option<String> = conn
.query_row( .query_row(
"SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'",
@@ -12989,13 +13430,11 @@ WHERE workspace_id = 'workspace-a'
[ [
"workspace_id", "workspace_id",
"repository_id", "repository_id",
"name", "repository_key",
"kind", "kind",
"provider", "provider",
"uri", "uri",
"default_ref", "default_ref",
"auth_ref_kind",
"auth_ref_key",
"created_at", "created_at",
"updated_at", "updated_at",
"source_kind", "source_kind",
@@ -13123,7 +13562,7 @@ WHERE workspace_id = 'workspace-a'
assign_explicit_test_workspace_owner(&conn); assign_explicit_test_workspace_owner(&conn);
let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 49); assert_eq!(store.schema_version().await.unwrap(), 50);
store store
.with_conn(|conn| { .with_conn(|conn| {
@@ -13312,7 +13751,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(), 49); assert_eq!(store.schema_version().await.unwrap(), 50);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: "owner-account".to_string(), owner_account_id: "owner-account".to_string(),
@@ -13326,7 +13765,7 @@ CREATE TABLE ticket_assignment_operations (
let repository = RepositoryRecord { let repository = RepositoryRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
repository_id: "main".to_string(), repository_id: "main".to_string(),
name: "Yoi".to_string(), repository_key: "yoi".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source: RepositorySource { source: RepositorySource {
@@ -13371,7 +13810,8 @@ CREATE TABLE ticket_assignment_operations (
store.upsert_workspace(&other_workspace).await.unwrap(); store.upsert_workspace(&other_workspace).await.unwrap();
let mut other_repository = repository.clone(); let mut other_repository = repository.clone();
other_repository.workspace_id = other_workspace.workspace_id.clone(); other_repository.workspace_id = other_workspace.workspace_id.clone();
other_repository.name = "Other Yoi".to_string(); other_repository.repository_id = "other-main".to_string();
other_repository.repository_key = "other-yoi".to_string();
other_repository.source.uri = "/other/yoi".to_string(); other_repository.source.uri = "/other/yoi".to_string();
other_repository.source_fingerprint = other_repository.source_fingerprint =
crate::repository_source::repository_source_fingerprint(&other_repository.source); crate::repository_source::repository_source_fingerprint(&other_repository.source);
@@ -13382,7 +13822,9 @@ CREATE TABLE ticket_assignment_operations (
Some(repository) Some(repository)
); );
assert_eq!( assert_eq!(
store.get_repository("other-workspace", "main").unwrap(), store
.get_repository("other-workspace", "other-main")
.unwrap(),
Some(other_repository) Some(other_repository)
); );
} }
@@ -13390,7 +13832,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(), 49); assert_eq!(store.schema_version().await.unwrap(), 50);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: "owner-account".to_string(), owner_account_id: "owner-account".to_string(),
@@ -13480,7 +13922,7 @@ CREATE TABLE ticket_assignment_operations (
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: workspace.workspace_id.clone(), workspace_id: workspace.workspace_id.clone(),
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
name: "Repository".to_string(), repository_key: "repository".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source: RepositorySource { source: RepositorySource {
@@ -13803,7 +14245,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(), 49); assert_eq!(store.schema_version().await.unwrap(), 50);
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(),
@@ -369,7 +369,7 @@ mod tests {
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: "workspace".to_string(), workspace_id: "workspace".to_string(),
repository_id: "main".to_string(), repository_id: "main".to_string(),
name: "main".to_string(), repository_key: "main".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source: workspace_api::RepositorySource { source: workspace_api::RepositorySource {
@@ -951,7 +951,7 @@ mod tests {
.upsert_repository(&RepositoryRecord { .upsert_repository(&RepositoryRecord {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
repository_id: "repository-a".to_string(), repository_id: "repository-a".to_string(),
name: "Repository A".to_string(), repository_key: "repository-a".to_string(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("local".to_string()), provider: Some("local".to_string()),
source: RepositorySource { source: RepositorySource {
@@ -13,17 +13,15 @@ use crate::store::{
}; };
use crate::{Error, Result}; use crate::{Error, Result};
const DEFAULT_REPOSITORY_ID: &str = "main";
const MAX_DISPLAY_NAME_BYTES: usize = 200; const MAX_DISPLAY_NAME_BYTES: usize = 200;
const MAX_OPERATION_KEY_BYTES: usize = 200; const MAX_OPERATION_KEY_BYTES: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct InitialRepositoryIntent { pub struct InitialRepositoryIntent {
pub repository_key: String,
pub uri: String, pub uri: String,
#[serde(default)] #[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub default_ref: Option<String>, pub default_ref: Option<String>,
} }
@@ -102,14 +100,9 @@ impl WorkspaceCatalogService {
normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?; normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?;
let repository_source = validate_repository_source(&request.repository.uri)?; let repository_source = validate_repository_source(&request.repository.uri)?;
let repository_uri = repository_source.uri.clone(); let repository_uri = repository_source.uri.clone();
let repository_name = request workspace_api::validate_repository_key(&request.repository.repository_key)
.repository .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
.display_name let repository_key = request.repository.repository_key.clone();
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Main repository")
.to_string();
let default_ref = request let default_ref = request
.repository .repository
.default_ref .default_ref
@@ -132,8 +125,8 @@ impl WorkspaceCatalogService {
requested_workspace_id.as_deref(), requested_workspace_id.as_deref(),
&display_name, &display_name,
Some(&owner_account_id), Some(&owner_account_id),
&repository_key,
&repository_uri, &repository_uri,
&repository_name,
&default_ref, &default_ref,
); );
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
@@ -152,8 +145,8 @@ impl WorkspaceCatalogService {
}, },
repository: RepositoryRecord { repository: RepositoryRecord {
workspace_id, workspace_id,
repository_id: DEFAULT_REPOSITORY_ID.to_string(), repository_id: Uuid::now_v7().to_string(),
name: repository_name, repository_key: repository_key.clone(),
kind: "git".to_string(), kind: "git".to_string(),
provider: Some("git".to_string()), provider: Some("git".to_string()),
source: repository_source.clone(), source: repository_source.clone(),
@@ -194,8 +187,8 @@ fn workspace_create_fingerprint(
requested_workspace_id: Option<&str>, requested_workspace_id: Option<&str>,
display_name: &str, display_name: &str,
owner_account_id: Option<&str>, owner_account_id: Option<&str>,
repository_key: &str,
repository_uri: &str, repository_uri: &str,
repository_name: &str,
default_ref: &str, default_ref: &str,
) -> String { ) -> String {
let payload = serde_json::json!({ let payload = serde_json::json!({
@@ -203,9 +196,8 @@ fn workspace_create_fingerprint(
"display_name": display_name, "display_name": display_name,
"owner_account_id": owner_account_id, "owner_account_id": owner_account_id,
"repository": { "repository": {
"repository_id": DEFAULT_REPOSITORY_ID, "repository_key": repository_key,
"uri": repository_uri, "uri": repository_uri,
"display_name": repository_name,
"default_ref": default_ref, "default_ref": default_ref,
"kind": "git", "kind": "git",
} }
@@ -258,7 +250,7 @@ mod tests {
display_name: "Workspace A".to_string(), display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent { repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(), uri: repository.path().display().to_string(),
display_name: None, repository_key: "main".to_string(),
default_ref: None, default_ref: None,
}, },
}; };
@@ -302,7 +294,7 @@ mod tests {
display_name: "Workspace A".to_string(), display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent { repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(), uri: repository.path().display().to_string(),
display_name: None, repository_key: "main".to_string(),
default_ref: None, default_ref: None,
}, },
}; };
@@ -355,7 +347,7 @@ mod tests {
display_name: "Organization Workspace".to_string(), display_name: "Organization Workspace".to_string(),
repository: InitialRepositoryIntent { repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(), uri: repository.path().display().to_string(),
display_name: None, repository_key: "main".to_string(),
default_ref: None, default_ref: None,
}, },
}, },
@@ -391,7 +383,7 @@ mod tests {
display_name: "Owner A Workspace".to_string(), display_name: "Owner A Workspace".to_string(),
repository: InitialRepositoryIntent { repository: InitialRepositoryIntent {
uri: repository_a.path().display().to_string(), uri: repository_a.path().display().to_string(),
display_name: None, repository_key: "main".to_string(),
default_ref: None, default_ref: None,
}, },
}, },
@@ -405,7 +397,7 @@ mod tests {
display_name: "Owner B Workspace".to_string(), display_name: "Owner B Workspace".to_string(),
repository: InitialRepositoryIntent { repository: InitialRepositoryIntent {
uri: repository_b.path().display().to_string(), uri: repository_b.path().display().to_string(),
display_name: None, repository_key: "main".to_string(),
default_ref: None, default_ref: None,
}, },
}, },
@@ -441,7 +433,7 @@ mod tests {
display_name: "Remote Workspace".to_string(), display_name: "Remote Workspace".to_string(),
repository: InitialRepositoryIntent { repository: InitialRepositoryIntent {
uri: "ssh://git@example.test/org/repository.git".to_string(), uri: "ssh://git@example.test/org/repository.git".to_string(),
display_name: Some("Remote Repository".to_string()), repository_key: "remote".to_string(),
default_ref: Some("main".to_string()), default_ref: Some("main".to_string()),
}, },
}, },