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