Skip to main content

ito_core/
sqlite_project_store.rs

1//! SQLite-backed [`BackendProjectStore`] proof-of-concept implementation.
2//!
3//! Stores project data (changes, modules, tasks) in a single SQLite database
4//! keyed by `{org}/{repo}`. This is a proof-of-concept demonstrating the
5//! storage abstraction — it stores serialized markdown content as blobs
6//! rather than fully normalized relational data.
7//!
8//! Database location: configurable via `BackendSqliteConfig::db_path`, with a
9//! default of `<data_dir>/sqlite/ito-backend.db`.
10
11use std::collections::BTreeSet;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14
15use chrono::Utc;
16use rusqlite::Connection;
17
18use ito_common::match_::nearest_matches;
19use ito_domain::backend::BackendProjectStore;
20use ito_domain::changes::{
21    Change, ChangeLifecycleFilter, ChangeRepository, ChangeSummary, ChangeTargetResolution,
22    ResolveTargetOptions, Spec, parse_change_id, parse_module_id,
23};
24use ito_domain::errors::{DomainError, DomainResult};
25use ito_domain::modules::{Module, ModuleRepository, ModuleSummary};
26use ito_domain::specs::{SpecDocument, SpecRepository, SpecSummary};
27use ito_domain::tasks::{
28    TaskInitResult, TaskMutationResult, TaskMutationService, TaskMutationServiceResult,
29    TaskRepository, TasksParseResult, parse_tasks_tracking_file,
30};
31use regex::Regex;
32
33use crate::errors::{CoreError, CoreResult};
34use crate::repository_runtime::RepositorySet;
35use crate::task_mutations::task_mutation_error_from_core;
36use crate::tasks::{
37    apply_add_task, apply_complete_task, apply_shelve_task, apply_start_task, apply_unshelve_task,
38    enhanced_tasks_template,
39};
40
41#[path = "sqlite_project_store_backend.rs"]
42mod backend_store;
43#[path = "sqlite_project_store_mutations.rs"]
44mod task_mutations_impl;
45
46#[path = "sqlite_project_store_repositories.rs"]
47mod repositories;
48
49use repositories::{
50    SqliteChangeRepository, SqliteModuleRepository, SqliteSpecRepository, SqliteTaskRepository,
51};
52use task_mutations_impl::SqliteTaskMutationService;
53
54/// Parameters for inserting or updating a change in the SQLite store.
55pub struct UpsertChangeParams<'a> {
56    /// Organization namespace.
57    pub org: &'a str,
58    /// Repository namespace.
59    pub repo: &'a str,
60    /// Change identifier.
61    pub change_id: &'a str,
62    /// Optional module this change belongs to.
63    pub module_id: Option<&'a str>,
64    /// Optional proposal markdown content.
65    pub proposal: Option<&'a str>,
66    /// Optional design markdown content.
67    pub design: Option<&'a str>,
68    /// Optional tasks.md content.
69    pub tasks_md: Option<&'a str>,
70    /// Spec deltas as `(capability, content)` pairs.
71    pub specs: &'a [(&'a str, &'a str)],
72}
73
74/// SQLite-backed project store using a single database file.
75///
76/// All projects share one database, namespaced by `{org}/{repo}`.
77/// The connection is protected by a `Mutex` for thread safety.
78pub struct SqliteBackendProjectStore {
79    conn: Arc<Mutex<Connection>>,
80}
81
82impl SqliteBackendProjectStore {
83    /// Open (or create) a SQLite project store at the given path.
84    pub fn open(db_path: &Path) -> Result<Self, CoreError> {
85        if let Some(parent) = db_path.parent() {
86            std::fs::create_dir_all(parent)
87                .map_err(|e| CoreError::io("creating sqlite database directory", e))?;
88        }
89
90        let conn = Connection::open(db_path)
91            .map_err(|e| CoreError::sqlite(format!("opening database: {e}")))?;
92
93        let store = Self {
94            conn: Arc::new(Mutex::new(conn)),
95        };
96        store.initialize_schema()?;
97        Ok(store)
98    }
99
100    /// Open an in-memory SQLite project store (for testing and integration tests).
101    pub fn open_in_memory() -> Result<Self, CoreError> {
102        let conn = Connection::open_in_memory()
103            .map_err(|e| CoreError::sqlite(format!("opening in-memory database: {e}")))?;
104        let store = Self {
105            conn: Arc::new(Mutex::new(conn)),
106        };
107        store.initialize_schema()?;
108        Ok(store)
109    }
110
111    fn initialize_schema(&self) -> Result<(), CoreError> {
112        let conn = self.lock_conn()?;
113        conn.execute_batch(
114            "CREATE TABLE IF NOT EXISTS projects (
115                org TEXT NOT NULL,
116                repo TEXT NOT NULL,
117                created_at TEXT NOT NULL,
118                PRIMARY KEY (org, repo)
119            );
120
121            CREATE TABLE IF NOT EXISTS changes (
122                org TEXT NOT NULL,
123                repo TEXT NOT NULL,
124                change_id TEXT NOT NULL,
125                module_id TEXT,
126                proposal TEXT,
127                design TEXT,
128                tasks_md TEXT,
129                archived_at TEXT,
130                created_at TEXT NOT NULL,
131                updated_at TEXT NOT NULL,
132                PRIMARY KEY (org, repo, change_id),
133                FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
134            );
135
136            CREATE TABLE IF NOT EXISTS change_specs (
137                org TEXT NOT NULL,
138                repo TEXT NOT NULL,
139                change_id TEXT NOT NULL,
140                capability TEXT NOT NULL,
141                content TEXT NOT NULL,
142                PRIMARY KEY (org, repo, change_id, capability),
143                FOREIGN KEY (org, repo, change_id)
144                    REFERENCES changes(org, repo, change_id)
145            );
146
147            CREATE TABLE IF NOT EXISTS modules (
148                org TEXT NOT NULL,
149                repo TEXT NOT NULL,
150                module_id TEXT NOT NULL,
151                name TEXT NOT NULL,
152                description TEXT,
153                created_at TEXT NOT NULL,
154                updated_at TEXT NOT NULL,
155                PRIMARY KEY (org, repo, module_id),
156                FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
157            );
158
159            CREATE TABLE IF NOT EXISTS promoted_specs (
160                org TEXT NOT NULL,
161                repo TEXT NOT NULL,
162                spec_id TEXT NOT NULL,
163                markdown TEXT NOT NULL,
164                updated_at TEXT NOT NULL,
165                PRIMARY KEY (org, repo, spec_id),
166                FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
167            );",
168        )
169        .map_err(|e| CoreError::sqlite(format!("initializing schema: {e}")))
170    }
171
172    /// Insert or update a change in the store (for seeding test data).
173    pub fn upsert_change(&self, params: &UpsertChangeParams<'_>) -> Result<(), CoreError> {
174        let UpsertChangeParams {
175            org,
176            repo,
177            change_id,
178            module_id,
179            proposal,
180            design,
181            tasks_md,
182            specs,
183        } = params;
184        let conn = self.lock_conn()?;
185        let now = Utc::now().to_rfc3339();
186
187        conn.execute(
188            "INSERT OR REPLACE INTO changes
189             (org, repo, change_id, module_id, proposal, design, tasks_md, created_at, updated_at)
190             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
191            rusqlite::params![
192                org, repo, change_id, module_id, proposal, design, tasks_md, now, now
193            ],
194        )
195        .map_err(|e| CoreError::sqlite(format!("upserting change: {e}")))?;
196
197        // Delete old specs and insert new
198        conn.execute(
199            "DELETE FROM change_specs WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
200            rusqlite::params![org, repo, change_id],
201        )
202        .map_err(|e| CoreError::sqlite(format!("deleting old specs: {e}")))?;
203
204        for (capability, content) in *specs {
205            conn.execute(
206                "INSERT INTO change_specs (org, repo, change_id, capability, content)
207                 VALUES (?1, ?2, ?3, ?4, ?5)",
208                rusqlite::params![org, repo, change_id, capability, content],
209            )
210            .map_err(|e| CoreError::sqlite(format!("inserting spec: {e}")))?;
211        }
212
213        Ok(())
214    }
215
216    /// Insert or update a module in the store (for seeding test data).
217    pub fn upsert_module(
218        &self,
219        org: &str,
220        repo: &str,
221        module_id: &str,
222        name: &str,
223        description: Option<&str>,
224    ) -> Result<(), CoreError> {
225        let conn = self.lock_conn()?;
226        let now = Utc::now().to_rfc3339();
227
228        conn.execute(
229            "INSERT OR REPLACE INTO modules
230             (org, repo, module_id, name, description, created_at, updated_at)
231             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
232            rusqlite::params![org, repo, module_id, name, description, now, now],
233        )
234        .map_err(|e| CoreError::sqlite(format!("upserting module: {e}")))?;
235
236        Ok(())
237    }
238
239    pub(crate) fn repository_set(&self, org: &str, repo: &str) -> CoreResult<RepositorySet> {
240        let conn = self.lock_conn()?;
241        let changes = load_changes_from_db(&conn, org, repo)?;
242        let modules = load_modules_from_db(&conn, org, repo)?;
243        let tasks_data = load_tasks_data_from_db(&conn, org, repo)?;
244        let specs = load_promoted_specs_from_db(&conn, org, repo)?;
245
246        Ok(RepositorySet {
247            changes: Arc::new(SqliteChangeRepository { changes }),
248            modules: Arc::new(SqliteModuleRepository { modules }),
249            tasks: Arc::new(SqliteTaskRepository { tasks_data }),
250            task_mutations: Arc::new(SqliteTaskMutationService {
251                conn: Arc::clone(&self.conn),
252                org: org.to_string(),
253                repo: repo.to_string(),
254            }),
255            specs: Arc::new(SqliteSpecRepository { specs }),
256        })
257    }
258
259    fn lock_conn(&self) -> DomainResult<std::sync::MutexGuard<'_, Connection>> {
260        self.conn.lock().map_err(|e| {
261            DomainError::io(
262                "locking sqlite connection",
263                std::io::Error::other(e.to_string()),
264            )
265        })
266    }
267}
268
269// ── Data loading helpers ───────────────────────────────────────────
270
271fn load_changes_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ChangeRow>> {
272    let mut stmt = conn
273        .prepare(
274            "SELECT change_id, module_id, proposal, design, tasks_md, created_at, updated_at, archived_at
275             FROM changes WHERE org = ?1 AND repo = ?2",
276        )
277        .map_err(|e| map_sqlite_err("preparing change query", e))?;
278
279    let rows = stmt
280        .query_map(rusqlite::params![org, repo], |row| {
281            Ok(ChangeRow {
282                change_id: row.get(0)?,
283                module_id: row.get(1)?,
284                proposal: row.get(2)?,
285                design: row.get(3)?,
286                tasks_md: row.get(4)?,
287                created_at: row.get(5)?,
288                updated_at: row.get(6)?,
289                archived_at: row.get(7)?,
290                specs: Vec::new(), // filled below
291            })
292        })
293        .map_err(|e| map_sqlite_err("querying changes", e))?;
294
295    let mut changes = Vec::new();
296    for row in rows {
297        let mut change = row.map_err(|e| map_sqlite_err("reading change row", e))?;
298
299        // Load specs for this change
300        let mut spec_stmt = conn
301            .prepare(
302                "SELECT capability, content FROM change_specs
303                 WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
304            )
305            .map_err(|e| map_sqlite_err("preparing spec query", e))?;
306
307        let spec_rows = spec_stmt
308            .query_map(rusqlite::params![org, repo, &change.change_id], |row| {
309                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
310            })
311            .map_err(|e| map_sqlite_err("querying specs", e))?;
312
313        for spec_row in spec_rows {
314            let (capability, content) =
315                spec_row.map_err(|e| map_sqlite_err("reading spec row", e))?;
316            change.specs.push((capability, content));
317        }
318
319        changes.push(change);
320    }
321
322    Ok(changes)
323}
324
325fn load_modules_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ModuleRow>> {
326    let mut stmt = conn
327        .prepare(
328            "SELECT module_id, name, description FROM modules
329             WHERE org = ?1 AND repo = ?2",
330        )
331        .map_err(|e| map_sqlite_err("preparing module query", e))?;
332
333    let rows = stmt
334        .query_map(rusqlite::params![org, repo], |row| {
335            Ok(ModuleRow {
336                module_id: row.get(0)?,
337                name: row.get(1)?,
338                description: row.get(2)?,
339            })
340        })
341        .map_err(|e| map_sqlite_err("querying modules", e))?;
342
343    let mut modules = Vec::new();
344    for row in rows {
345        modules.push(row.map_err(|e| map_sqlite_err("reading module row", e))?);
346    }
347
348    Ok(modules)
349}
350
351/// Mapping of change_id -> tasks_md content for task lookups.
352fn load_tasks_data_from_db(
353    conn: &Connection,
354    org: &str,
355    repo: &str,
356) -> DomainResult<Vec<(String, Option<String>)>> {
357    let mut stmt = conn
358        .prepare("SELECT change_id, tasks_md FROM changes WHERE org = ?1 AND repo = ?2")
359        .map_err(|e| map_sqlite_err("preparing tasks query", e))?;
360
361    let rows = stmt
362        .query_map(rusqlite::params![org, repo], |row| {
363            Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
364        })
365        .map_err(|e| map_sqlite_err("querying tasks data", e))?;
366
367    let mut data = Vec::new();
368    for row in rows {
369        data.push(row.map_err(|e| map_sqlite_err("reading tasks row", e))?);
370    }
371
372    Ok(data)
373}
374
375fn load_promoted_specs_from_db(
376    conn: &Connection,
377    org: &str,
378    repo: &str,
379) -> DomainResult<Vec<SpecDocument>> {
380    let mut stmt = conn
381        .prepare(
382            "SELECT spec_id, markdown, updated_at FROM promoted_specs WHERE org = ?1 AND repo = ?2",
383        )
384        .map_err(|e| map_sqlite_err("preparing promoted specs query", e))?;
385
386    let rows = stmt
387        .query_map(rusqlite::params![org, repo], |row| {
388            Ok((
389                row.get::<_, String>(0)?,
390                row.get::<_, String>(1)?,
391                row.get::<_, String>(2)?,
392            ))
393        })
394        .map_err(|e| map_sqlite_err("querying promoted specs", e))?;
395
396    let mut specs = Vec::new();
397    for row in rows {
398        let (id, markdown, updated_at) =
399            row.map_err(|e| map_sqlite_err("reading promoted spec row", e))?;
400        let last_modified = chrono::DateTime::parse_from_rfc3339(&updated_at)
401            .map(|dt| dt.with_timezone(&Utc))
402            .unwrap_or_else(|_| Utc::now());
403        specs.push(SpecDocument {
404            id: id.clone(),
405            path: PathBuf::from(format!(".ito/specs/{id}/spec.md")),
406            markdown,
407            last_modified,
408        });
409    }
410    specs.sort_by(|left, right| left.id.cmp(&right.id));
411    Ok(specs)
412}
413
414fn map_sqlite_err(context: &'static str, err: rusqlite::Error) -> DomainError {
415    DomainError::io(context, std::io::Error::other(err.to_string()))
416}
417
418// ── In-memory row types ────────────────────────────────────────────
419
420#[derive(Debug)]
421struct ChangeRow {
422    change_id: String,
423    module_id: Option<String>,
424    proposal: Option<String>,
425    design: Option<String>,
426    tasks_md: Option<String>,
427    #[allow(dead_code)]
428    created_at: String,
429    updated_at: String,
430    archived_at: Option<String>,
431    specs: Vec<(String, String)>,
432}
433
434#[derive(Debug)]
435struct ModuleRow {
436    module_id: String,
437    name: String,
438    description: Option<String>,
439}