1use super::*;
2
3impl SqliteBackendProjectStore {
4 fn with_backend_transaction<T, F>(&self, op: F) -> Result<T, ito_domain::backend::BackendError>
5 where
6 F: FnOnce(&rusqlite::Transaction<'_>) -> Result<T, ito_domain::backend::BackendError>,
7 {
8 let mut conn = self
9 .lock_conn()
10 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
11 let tx = conn.transaction().map_err(|err| {
12 ito_domain::backend::BackendError::Other(format!("starting sqlite transaction: {err}"))
13 })?;
14 let result = op(&tx)?;
15 tx.commit().map_err(|err| {
16 ito_domain::backend::BackendError::Other(format!(
17 "committing sqlite transaction: {err}"
18 ))
19 })?;
20 Ok(result)
21 }
22}
23
24impl BackendProjectStore for SqliteBackendProjectStore {
25 fn change_repository(
26 &self,
27 org: &str,
28 repo: &str,
29 ) -> DomainResult<Box<dyn ChangeRepository + Send>> {
30 let conn = self.lock_conn()?;
31 let changes = load_changes_from_db(&conn, org, repo)?;
32 Ok(Box::new(SqliteChangeRepository { changes }))
33 }
34
35 fn module_repository(
36 &self,
37 org: &str,
38 repo: &str,
39 ) -> DomainResult<Box<dyn ModuleRepository + Send>> {
40 let conn = self.lock_conn()?;
41 let modules = load_modules_from_db(&conn, org, repo)?;
42 Ok(Box::new(SqliteModuleRepository { modules }))
43 }
44
45 fn task_repository(
46 &self,
47 org: &str,
48 repo: &str,
49 ) -> DomainResult<Box<dyn TaskRepository + Send>> {
50 let conn = self.lock_conn()?;
51 let tasks_data = load_tasks_data_from_db(&conn, org, repo)?;
52 Ok(Box::new(SqliteTaskRepository { tasks_data }))
53 }
54
55 fn task_mutation_service(
56 &self,
57 org: &str,
58 repo: &str,
59 ) -> DomainResult<Box<dyn TaskMutationService + Send>> {
60 Ok(Box::new(SqliteTaskMutationService {
61 conn: Arc::clone(&self.conn),
62 org: org.to_string(),
63 repo: repo.to_string(),
64 }))
65 }
66
67 fn spec_repository(
68 &self,
69 org: &str,
70 repo: &str,
71 ) -> DomainResult<Box<dyn SpecRepository + Send>> {
72 let conn = self.lock_conn()?;
73 let specs = load_promoted_specs_from_db(&conn, org, repo)?;
74 Ok(Box::new(SqliteSpecRepository { specs }))
75 }
76
77 fn pull_artifact_bundle(
78 &self,
79 org: &str,
80 repo: &str,
81 change_id: &str,
82 ) -> Result<ito_domain::backend::ArtifactBundle, ito_domain::backend::BackendError> {
83 let conn = self
84 .lock_conn()
85 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
86 let changes = load_changes_from_db(&conn, org, repo)
87 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
88 let Some(change) = changes
89 .into_iter()
90 .find(|change| change.change_id == change_id)
91 else {
92 return Err(ito_domain::backend::BackendError::NotFound(format!(
93 "change '{change_id}'"
94 )));
95 };
96
97 Ok(ito_domain::backend::ArtifactBundle {
98 change_id: change.change_id.clone(),
99 proposal: change.proposal,
100 design: change.design,
101 tasks: change.tasks_md,
102 specs: change.specs,
103 revision: change.updated_at,
104 })
105 }
106
107 fn push_artifact_bundle(
108 &self,
109 org: &str,
110 repo: &str,
111 change_id: &str,
112 bundle: &ito_domain::backend::ArtifactBundle,
113 ) -> Result<ito_domain::backend::PushResult, ito_domain::backend::BackendError> {
114 let now = Utc::now().to_rfc3339();
115 self.with_backend_transaction(|tx| {
116 let current_updated_at: Result<String, rusqlite::Error> = tx.query_row(
117 "SELECT updated_at FROM changes WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
118 rusqlite::params![org, repo, change_id],
119 |row| row.get(0),
120 );
121 let current_updated_at = match current_updated_at {
122 Ok(value) => value,
123 Err(rusqlite::Error::QueryReturnedNoRows) => {
124 return Err(ito_domain::backend::BackendError::NotFound(format!(
125 "change '{change_id}'"
126 )));
127 }
128 Err(err) => {
129 return Err(ito_domain::backend::BackendError::Other(err.to_string()));
130 }
131 };
132
133 if !bundle.revision.trim().is_empty() && bundle.revision != current_updated_at {
134 return Err(ito_domain::backend::BackendError::RevisionConflict(
135 ito_domain::backend::RevisionConflict {
136 change_id: change_id.to_string(),
137 local_revision: bundle.revision.clone(),
138 server_revision: current_updated_at,
139 },
140 ));
141 }
142
143 tx.execute(
144 "UPDATE changes SET proposal = ?1, design = ?2, tasks_md = ?3, updated_at = ?4 WHERE org = ?5 AND repo = ?6 AND change_id = ?7",
145 rusqlite::params![
146 bundle.proposal,
147 bundle.design,
148 bundle.tasks,
149 &now,
150 org,
151 repo,
152 change_id,
153 ],
154 )
155 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
156
157 tx.execute(
158 "DELETE FROM change_specs WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
159 rusqlite::params![org, repo, change_id],
160 )
161 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
162 for (capability, markdown) in &bundle.specs {
163 tx.execute(
164 "INSERT INTO change_specs (org, repo, change_id, capability, content) VALUES (?1, ?2, ?3, ?4, ?5)",
165 rusqlite::params![org, repo, change_id, capability, markdown],
166 )
167 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
168 }
169
170 Ok(ito_domain::backend::PushResult {
171 change_id: change_id.to_string(),
172 new_revision: now.clone(),
173 })
174 })
175 }
176
177 fn archive_change(
178 &self,
179 org: &str,
180 repo: &str,
181 change_id: &str,
182 ) -> Result<ito_domain::backend::ArchiveResult, ito_domain::backend::BackendError> {
183 let archived_at = Utc::now().to_rfc3339();
184 self.with_backend_transaction(|tx| {
185 let changes = load_changes_from_db(tx, org, repo)
186 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
187 let Some(change) = changes.into_iter().find(|change| change.change_id == change_id)
188 else {
189 return Err(ito_domain::backend::BackendError::NotFound(format!(
190 "change '{change_id}'"
191 )));
192 };
193
194 for (spec_id, markdown) in &change.specs {
195 tx.execute(
196 "INSERT OR REPLACE INTO promoted_specs (org, repo, spec_id, markdown, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
197 rusqlite::params![org, repo, spec_id, markdown, &archived_at],
198 )
199 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
200 }
201 tx.execute(
202 "UPDATE changes SET archived_at = ?1, updated_at = ?1 WHERE org = ?2 AND repo = ?3 AND change_id = ?4",
203 rusqlite::params![&archived_at, org, repo, change_id],
204 )
205 .map_err(|err| ito_domain::backend::BackendError::Other(err.to_string()))?;
206
207 Ok(ito_domain::backend::ArchiveResult {
208 change_id: change_id.to_string(),
209 archived_at: archived_at.clone(),
210 })
211 })
212 }
213
214 fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()> {
215 let conn = self.lock_conn()?;
216 let now = Utc::now().to_rfc3339();
217 conn.execute(
218 "INSERT OR IGNORE INTO projects (org, repo, created_at) VALUES (?1, ?2, ?3)",
219 rusqlite::params![org, repo, now],
220 )
221 .map_err(|e| {
222 DomainError::io(
223 "creating project in sqlite",
224 std::io::Error::other(e.to_string()),
225 )
226 })?;
227 Ok(())
228 }
229
230 fn project_exists(&self, org: &str, repo: &str) -> bool {
231 let Ok(conn) = self.lock_conn() else {
232 return false;
233 };
234 conn.query_row(
235 "SELECT 1 FROM projects WHERE org = ?1 AND repo = ?2",
236 rusqlite::params![org, repo],
237 |_| Ok(()),
238 )
239 .is_ok()
240 }
241}