feat: introduce workspace-scoped repository keys
This commit is contained in:
@@ -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!({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user