Skip to main content

ito_core/
sqlite_project_store_backend.rs

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}