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 sub-module this change belongs to (e.g., `"005.01"`).
65    pub sub_module_id: Option<&'a str>,
66    /// Optional proposal markdown content.
67    pub proposal: Option<&'a str>,
68    /// Optional design markdown content.
69    pub design: Option<&'a str>,
70    /// Optional tasks.md content.
71    pub tasks_md: Option<&'a str>,
72    /// Spec deltas as `(capability, content)` pairs.
73    pub specs: &'a [(&'a str, &'a str)],
74}
75
76/// SQLite-backed project store using a single database file.
77///
78/// All projects share one database, namespaced by `{org}/{repo}`.
79/// The connection is protected by a `Mutex` for thread safety.
80pub struct SqliteBackendProjectStore {
81    conn: Arc<Mutex<Connection>>,
82}
83
84impl SqliteBackendProjectStore {
85    /// Open (or create) a SQLite project store at the given path.
86    pub fn open(db_path: &Path) -> Result<Self, CoreError> {
87        if let Some(parent) = db_path.parent() {
88            std::fs::create_dir_all(parent)
89                .map_err(|e| CoreError::io("creating sqlite database directory", e))?;
90        }
91
92        let conn = Connection::open(db_path)
93            .map_err(|e| CoreError::sqlite(format!("opening database: {e}")))?;
94
95        let store = Self {
96            conn: Arc::new(Mutex::new(conn)),
97        };
98        store.initialize_schema()?;
99        Ok(store)
100    }
101
102    /// Open an in-memory SQLite project store (for testing and integration tests).
103    pub fn open_in_memory() -> Result<Self, CoreError> {
104        let conn = Connection::open_in_memory()
105            .map_err(|e| CoreError::sqlite(format!("opening in-memory database: {e}")))?;
106        let store = Self {
107            conn: Arc::new(Mutex::new(conn)),
108        };
109        store.initialize_schema()?;
110        Ok(store)
111    }
112
113    fn initialize_schema(&self) -> Result<(), CoreError> {
114        let conn = self.lock_conn()?;
115        conn.execute_batch(
116            "CREATE TABLE IF NOT EXISTS projects (
117                org TEXT NOT NULL,
118                repo TEXT NOT NULL,
119                created_at TEXT NOT NULL,
120                PRIMARY KEY (org, repo)
121            );
122
123            CREATE TABLE IF NOT EXISTS changes (
124                org TEXT NOT NULL,
125                repo TEXT NOT NULL,
126                change_id TEXT NOT NULL,
127                module_id TEXT,
128                sub_module_id TEXT,
129                proposal TEXT,
130                design TEXT,
131                tasks_md TEXT,
132                archived_at TEXT,
133                created_at TEXT NOT NULL,
134                updated_at TEXT NOT NULL,
135                PRIMARY KEY (org, repo, change_id),
136                FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
137            );
138
139            CREATE TABLE IF NOT EXISTS change_specs (
140                org TEXT NOT NULL,
141                repo TEXT NOT NULL,
142                change_id TEXT NOT NULL,
143                capability TEXT NOT NULL,
144                content TEXT NOT NULL,
145                PRIMARY KEY (org, repo, change_id, capability),
146                FOREIGN KEY (org, repo, change_id)
147                    REFERENCES changes(org, repo, change_id)
148            );
149
150            CREATE TABLE IF NOT EXISTS modules (
151                org TEXT NOT NULL,
152                repo TEXT NOT NULL,
153                module_id TEXT NOT NULL,
154                name TEXT NOT NULL,
155                description TEXT,
156                created_at TEXT NOT NULL,
157                updated_at TEXT NOT NULL,
158                PRIMARY KEY (org, repo, module_id),
159                FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
160            );
161
162            CREATE TABLE IF NOT EXISTS promoted_specs (
163                org TEXT NOT NULL,
164                repo TEXT NOT NULL,
165                spec_id TEXT NOT NULL,
166                markdown TEXT NOT NULL,
167                updated_at TEXT NOT NULL,
168                PRIMARY KEY (org, repo, spec_id),
169                FOREIGN KEY (org, repo) REFERENCES projects(org, repo)
170            );",
171        )
172        .map_err(|e| CoreError::sqlite(format!("initializing schema: {e}")))?;
173
174        // Migrate pre-existing databases that were created before the sub_module_id column
175        // was added.  SQLite does not support IF NOT EXISTS in ALTER TABLE, so we probe
176        // pragma_table_info first and only run the migration when the column is absent.
177        let has_col = conn
178            .query_row(
179                "SELECT COUNT(*) FROM pragma_table_info('changes') WHERE name = 'sub_module_id'",
180                [],
181                |row| row.get::<_, i64>(0),
182            )
183            .map_err(|e| CoreError::sqlite(format!("checking schema migration: {e}")))?
184            > 0;
185        if !has_col {
186            conn.execute_batch("ALTER TABLE changes ADD COLUMN sub_module_id TEXT")
187                .map_err(|e| CoreError::sqlite(format!("migrating schema (sub_module_id): {e}")))?;
188        }
189
190        Ok(())
191    }
192
193    /// Insert or update a change in the store (for seeding test data).
194    pub fn upsert_change(&self, params: &UpsertChangeParams<'_>) -> Result<(), CoreError> {
195        let UpsertChangeParams {
196            org,
197            repo,
198            change_id,
199            module_id,
200            sub_module_id,
201            proposal,
202            design,
203            tasks_md,
204            specs,
205        } = params;
206        let conn = self.lock_conn()?;
207        let now = Utc::now().to_rfc3339();
208
209        conn.execute(
210            "INSERT OR REPLACE INTO changes
211             (org, repo, change_id, module_id, sub_module_id, proposal, design, tasks_md, created_at, updated_at)
212             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
213            rusqlite::params![
214                org, repo, change_id, module_id, sub_module_id, proposal, design, tasks_md, now, now
215            ],
216        )
217        .map_err(|e| CoreError::sqlite(format!("upserting change: {e}")))?;
218
219        // Delete old specs and insert new
220        conn.execute(
221            "DELETE FROM change_specs WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
222            rusqlite::params![org, repo, change_id],
223        )
224        .map_err(|e| CoreError::sqlite(format!("deleting old specs: {e}")))?;
225
226        for (capability, content) in *specs {
227            conn.execute(
228                "INSERT INTO change_specs (org, repo, change_id, capability, content)
229                 VALUES (?1, ?2, ?3, ?4, ?5)",
230                rusqlite::params![org, repo, change_id, capability, content],
231            )
232            .map_err(|e| CoreError::sqlite(format!("inserting spec: {e}")))?;
233        }
234
235        Ok(())
236    }
237
238    /// Insert or update a module in the store (for seeding test data).
239    pub fn upsert_module(
240        &self,
241        org: &str,
242        repo: &str,
243        module_id: &str,
244        name: &str,
245        description: Option<&str>,
246    ) -> Result<(), CoreError> {
247        let conn = self.lock_conn()?;
248        let now = Utc::now().to_rfc3339();
249
250        conn.execute(
251            "INSERT OR REPLACE INTO modules
252             (org, repo, module_id, name, description, created_at, updated_at)
253             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
254            rusqlite::params![org, repo, module_id, name, description, now, now],
255        )
256        .map_err(|e| CoreError::sqlite(format!("upserting module: {e}")))?;
257
258        Ok(())
259    }
260
261    pub(crate) fn repository_set(&self, org: &str, repo: &str) -> CoreResult<RepositorySet> {
262        let conn = self.lock_conn()?;
263        let changes = load_changes_from_db(&conn, org, repo)?;
264        let modules = load_modules_from_db(&conn, org, repo)?;
265        let tasks_data = load_tasks_data_from_db(&conn, org, repo)?;
266        let specs = load_promoted_specs_from_db(&conn, org, repo)?;
267
268        Ok(RepositorySet {
269            changes: Arc::new(SqliteChangeRepository { changes }),
270            modules: Arc::new(SqliteModuleRepository { modules }),
271            tasks: Arc::new(SqliteTaskRepository { tasks_data }),
272            task_mutations: Arc::new(SqliteTaskMutationService {
273                conn: Arc::clone(&self.conn),
274                org: org.to_string(),
275                repo: repo.to_string(),
276            }),
277            specs: Arc::new(SqliteSpecRepository { specs }),
278        })
279    }
280
281    fn lock_conn(&self) -> DomainResult<std::sync::MutexGuard<'_, Connection>> {
282        self.conn.lock().map_err(|e| {
283            DomainError::io(
284                "locking sqlite connection",
285                std::io::Error::other(e.to_string()),
286            )
287        })
288    }
289}
290
291// ── Data loading helpers ───────────────────────────────────────────
292
293fn load_changes_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ChangeRow>> {
294    let mut stmt = conn
295        .prepare(
296            "SELECT change_id, module_id, sub_module_id, proposal, design, tasks_md, created_at, updated_at, archived_at
297             FROM changes WHERE org = ?1 AND repo = ?2",
298        )
299        .map_err(|e| map_sqlite_err("preparing change query", e))?;
300
301    let rows = stmt
302        .query_map(rusqlite::params![org, repo], |row| {
303            Ok(ChangeRow {
304                change_id: row.get(0)?,
305                module_id: row.get(1)?,
306                sub_module_id: row.get(2)?,
307                proposal: row.get(3)?,
308                design: row.get(4)?,
309                tasks_md: row.get(5)?,
310                created_at: row.get(6)?,
311                updated_at: row.get(7)?,
312                archived_at: row.get(8)?,
313                specs: Vec::new(), // filled below
314            })
315        })
316        .map_err(|e| map_sqlite_err("querying changes", e))?;
317
318    let mut changes = Vec::new();
319    for row in rows {
320        let mut change = row.map_err(|e| map_sqlite_err("reading change row", e))?;
321
322        // Load specs for this change
323        let mut spec_stmt = conn
324            .prepare(
325                "SELECT capability, content FROM change_specs
326                 WHERE org = ?1 AND repo = ?2 AND change_id = ?3",
327            )
328            .map_err(|e| map_sqlite_err("preparing spec query", e))?;
329
330        let spec_rows = spec_stmt
331            .query_map(rusqlite::params![org, repo, &change.change_id], |row| {
332                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
333            })
334            .map_err(|e| map_sqlite_err("querying specs", e))?;
335
336        for spec_row in spec_rows {
337            let (capability, content) =
338                spec_row.map_err(|e| map_sqlite_err("reading spec row", e))?;
339            change.specs.push((capability, content));
340        }
341
342        changes.push(change);
343    }
344
345    Ok(changes)
346}
347
348fn load_modules_from_db(conn: &Connection, org: &str, repo: &str) -> DomainResult<Vec<ModuleRow>> {
349    let mut stmt = conn
350        .prepare(
351            "SELECT module_id, name, description FROM modules
352             WHERE org = ?1 AND repo = ?2",
353        )
354        .map_err(|e| map_sqlite_err("preparing module query", e))?;
355
356    let rows = stmt
357        .query_map(rusqlite::params![org, repo], |row| {
358            Ok(ModuleRow {
359                module_id: row.get(0)?,
360                name: row.get(1)?,
361                description: row.get(2)?,
362            })
363        })
364        .map_err(|e| map_sqlite_err("querying modules", e))?;
365
366    let mut modules = Vec::new();
367    for row in rows {
368        modules.push(row.map_err(|e| map_sqlite_err("reading module row", e))?);
369    }
370
371    Ok(modules)
372}
373
374/// Mapping of change_id -> tasks_md content for task lookups.
375fn load_tasks_data_from_db(
376    conn: &Connection,
377    org: &str,
378    repo: &str,
379) -> DomainResult<Vec<(String, Option<String>)>> {
380    let mut stmt = conn
381        .prepare("SELECT change_id, tasks_md FROM changes WHERE org = ?1 AND repo = ?2")
382        .map_err(|e| map_sqlite_err("preparing tasks query", e))?;
383
384    let rows = stmt
385        .query_map(rusqlite::params![org, repo], |row| {
386            Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
387        })
388        .map_err(|e| map_sqlite_err("querying tasks data", e))?;
389
390    let mut data = Vec::new();
391    for row in rows {
392        data.push(row.map_err(|e| map_sqlite_err("reading tasks row", e))?);
393    }
394
395    Ok(data)
396}
397
398fn load_promoted_specs_from_db(
399    conn: &Connection,
400    org: &str,
401    repo: &str,
402) -> DomainResult<Vec<SpecDocument>> {
403    let mut stmt = conn
404        .prepare(
405            "SELECT spec_id, markdown, updated_at FROM promoted_specs WHERE org = ?1 AND repo = ?2",
406        )
407        .map_err(|e| map_sqlite_err("preparing promoted specs query", e))?;
408
409    let rows = stmt
410        .query_map(rusqlite::params![org, repo], |row| {
411            Ok((
412                row.get::<_, String>(0)?,
413                row.get::<_, String>(1)?,
414                row.get::<_, String>(2)?,
415            ))
416        })
417        .map_err(|e| map_sqlite_err("querying promoted specs", e))?;
418
419    let mut specs = Vec::new();
420    for row in rows {
421        let (id, markdown, updated_at) =
422            row.map_err(|e| map_sqlite_err("reading promoted spec row", e))?;
423        let last_modified = chrono::DateTime::parse_from_rfc3339(&updated_at)
424            .map(|dt| dt.with_timezone(&Utc))
425            .unwrap_or_else(|_| Utc::now());
426        specs.push(SpecDocument {
427            id: id.clone(),
428            path: PathBuf::from(format!(".ito/specs/{id}/spec.md")),
429            markdown,
430            last_modified,
431        });
432    }
433    specs.sort_by(|left, right| left.id.cmp(&right.id));
434    Ok(specs)
435}
436
437fn map_sqlite_err(context: &'static str, err: rusqlite::Error) -> DomainError {
438    DomainError::io(context, std::io::Error::other(err.to_string()))
439}
440
441// ── In-memory row types ────────────────────────────────────────────
442
443#[derive(Debug)]
444struct ChangeRow {
445    change_id: String,
446    module_id: Option<String>,
447    sub_module_id: Option<String>,
448    proposal: Option<String>,
449    design: Option<String>,
450    tasks_md: Option<String>,
451    #[allow(dead_code)]
452    created_at: String,
453    updated_at: String,
454    archived_at: Option<String>,
455    specs: Vec<(String, String)>,
456}
457
458#[derive(Debug)]
459struct ModuleRow {
460    module_id: String,
461    name: String,
462    description: Option<String>,
463}