Skip to main content

ito_core/
sqlite_project_store_backend.rs

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}