use super::*;
use rusqlite::OptionalExtension;
impl SqliteBackendProjectStore {
fn with_backend_transaction<T, F>(&self, op: F) -> Result<T, ito_domain::backend::BackendError>
where
F: FnOnce(&rusqlite::Transaction<'_>) -> Result<T, ito_domain::backend::BackendError>,
{
let mut conn = self
.lock_conn()
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
let tx = conn.transaction().map_err(|err| {
ito_domain::backend::BackendError::Other(format!("starting sqlite transaction: {err}"))
})?;
let result = op(&tx)?;
tx.commit().map_err(|err| {
ito_domain::backend::BackendError::Other(format!(
"committing sqlite transaction: {err}"
))
})?;
Ok(result)
}
}
impl BackendProjectStore for SqliteBackendProjectStore {
fn change_repository(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn ChangeRepository + Send>> {
let conn = self.lock_conn()?;
let changes = load_changes_from_db(&conn, org, repo)?;
Ok(Box::new(SqliteChangeRepository { changes }))
}
fn module_repository(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn ModuleRepository + Send>> {
let conn = self.lock_conn()?;
let modules = load_modules_from_db(&conn, org, repo)?;
Ok(Box::new(SqliteModuleRepository { modules }))
}
fn task_repository(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn TaskRepository + Send>> {
let conn = self.lock_conn()?;
let tasks_data = load_tasks_data_from_db(&conn, org, repo)?;
Ok(Box::new(SqliteTaskRepository { tasks_data }))
}
fn task_mutation_service(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn TaskMutationService + Send>> {
Ok(Box::new(SqliteTaskMutationService {
conn: Arc::clone(&self.conn),
org: org.to_string(),
repo: repo.to_string(),
}))
}
fn spec_repository(
&self,
org: &str,
repo: &str,
) -> DomainResult<Box<dyn SpecRepository + Send>> {
let conn = self.lock_conn()?;
let specs = load_promoted_specs_from_db(&conn, org, repo)?;
Ok(Box::new(SqliteSpecRepository { specs }))
}
fn pull_artifact_bundle(
&self,
org: &str,
repo: &str,
change_id: &str,
) -> Result<ito_domain::backend::ArtifactBundle, ito_domain::backend::BackendError> {
let conn = self
.lock_conn()
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
let changes = load_changes_from_db(&conn, org, repo)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
let Some(change) = changes
.into_iter()
.find(|change| change.change_id == change_id)
else {
return Err(ito_domain::backend::BackendError::NotFound(format!(
"change '{change_id}'"
)));
};
Ok(ito_domain::backend::ArtifactBundle {
change_id: change.change_id.clone(),
proposal: change.proposal,
design: change.design,
tasks: change.tasks_md,
specs: change.specs,
revision: change.updated_at,
})
}
fn push_artifact_bundle(
&self,
org: &str,
repo: &str,
change_id: &str,
bundle: &ito_domain::backend::ArtifactBundle,
) -> Result<ito_domain::backend::PushResult, ito_domain::backend::BackendError> {
let now = Utc::now().to_rfc3339();
self.with_backend_transaction(|tx| {
let current_updated_at: Result<String, rusqlite::Error> = tx.query_row(
"SELECT updated_at FROM changes WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
rusqlite::params![org, repo, change_id],
|row| row.get(0),
);
let current_updated_at = match current_updated_at {
Ok(value) => value,
Err(rusqlite::Error::QueryReturnedNoRows) => {
return Err(ito_domain::backend::BackendError::NotFound(format!(
"change '{change_id}'"
)));
}
Err(err) => {
return Err(ito_domain::backend::BackendError::Other(err.to_string()));
}
};
if !bundle.revision.trim().is_empty() && bundle.revision != current_updated_at {
return Err(ito_domain::backend::BackendError::RevisionConflict(
ito_domain::backend::RevisionConflict {
change_id: change_id.to_string(),
local_revision: bundle.revision.clone(),
server_revision: current_updated_at,
},
));
}
tx.execute(
"UPDATE changes SET proposal = ?1, design = ?2, tasks_md = ?3, updated_at = ?4 WHERE org = ?5 AND repo = ?6 AND change_id = ?7",
rusqlite::params![
bundle.proposal,
bundle.design,
bundle.tasks,
&now,
org,
repo,
change_id,
],
)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
tx.execute(
"DELETE FROM change_specs WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
rusqlite::params![org, repo, change_id],
)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
for (capability, markdown) in &bundle.specs {
tx.execute(
"INSERT INTO change_specs (org, repo, change_id, capability, content) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![org, repo, change_id, capability, markdown],
)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
}
Ok(ito_domain::backend::PushResult {
change_id: change_id.to_string(),
new_revision: now.clone(),
})
})
}
fn archive_change(
&self,
org: &str,
repo: &str,
change_id: &str,
) -> Result<ito_domain::backend::ArchiveResult, ito_domain::backend::BackendError> {
let archived_at = Utc::now().to_rfc3339();
self.with_backend_transaction(|tx| {
let changes = load_changes_from_db(tx, org, repo)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
let Some(change) = changes.into_iter().find(|change| change.change_id == change_id)
else {
return Err(ito_domain::backend::BackendError::NotFound(format!(
"change '{change_id}'"
)));
};
for (spec_id, markdown) in &change.specs {
let current = tx
.query_row(
"SELECT markdown FROM promoted_specs WHERE org = ?1 AND repo = ?2 AND spec_id = ?3",
rusqlite::params![org, repo, spec_id],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
let reconciled = crate::archive::reconcile_spec_markdown(
current.as_deref(),
markdown,
)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
if let Some(reconciled) = reconciled {
tx.execute(
"INSERT OR REPLACE INTO promoted_specs (org, repo, spec_id, markdown, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![org, repo, spec_id, reconciled, &archived_at],
)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
} else {
tx.execute(
"DELETE FROM promoted_specs WHERE org = ?1 AND repo = ?2 AND spec_id = ?3",
rusqlite::params![org, repo, spec_id],
)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
}
}
tx.execute(
"UPDATE changes SET archived_at = ?1, updated_at = ?1 WHERE org = ?2 AND repo = ?3 AND change_id = ?4",
rusqlite::params![&archived_at, org, repo, change_id],
)
.map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
Ok(ito_domain::backend::ArchiveResult {
change_id: change_id.to_string(),
archived_at: archived_at.clone(),
})
})
}
fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()> {
let conn = self.lock_conn()?;
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT OR IGNORE INTO projects (org, repo, created_at) VALUES (?1, ?2, ?3)",
rusqlite::params![org, repo, now],
)
.map_err(|e| {
DomainError::io(
"creating project in sqlite",
std::io::Error::other(e.to_string()),
)
})?;
Ok(())
}
fn project_exists(&self, org: &str, repo: &str) -> bool {
let Ok(conn) = self.lock_conn() else {
return false;
};
conn.query_row(
"SELECT 1 FROM projects WHERE org = ?1 AND repo = ?2",
rusqlite::params![org, repo],
|_| Ok(()),
)
.is_ok()
}
}