Skip to main content

sc/storage/
schema.rs

1//! Database schema definitions and migration logic.
2//!
3//! This module contains the complete SQLite schema for SaveContext,
4//! ported from the TypeScript MCP server's schema.sql.
5
6use rusqlite::{Connection, Result};
7
8/// Current schema version for migration tracking.
9pub const CURRENT_SCHEMA_VERSION: i32 = 1;
10
11/// The complete SQL schema for the SaveContext database.
12///
13/// Note: Timestamps are stored as INTEGER (Unix milliseconds) for consistency
14/// with the existing TypeScript implementation.
15pub const SCHEMA_SQL: &str = r#"
16-- ====================
17-- Schema Version Tracking
18-- ====================
19
20CREATE TABLE IF NOT EXISTS schema_migrations (
21    version TEXT PRIMARY KEY,
22    applied_at INTEGER NOT NULL
23);
24
25-- ====================
26-- Core Tables
27-- ====================
28
29-- Projects: Registry for ID generation and metadata
30CREATE TABLE IF NOT EXISTS projects (
31    id TEXT PRIMARY KEY,
32    project_path TEXT NOT NULL UNIQUE,
33    name TEXT NOT NULL,
34    description TEXT,
35    issue_prefix TEXT,
36    next_issue_number INTEGER DEFAULT 1,
37    plan_prefix TEXT,
38    next_plan_number INTEGER DEFAULT 1,
39    created_at INTEGER NOT NULL,
40    updated_at INTEGER NOT NULL
41);
42
43CREATE INDEX IF NOT EXISTS idx_projects_path ON projects(project_path);
44
45-- Sessions: Track conversation sessions
46CREATE TABLE IF NOT EXISTS sessions (
47    id TEXT PRIMARY KEY,
48    name TEXT NOT NULL,
49    description TEXT,
50    branch TEXT,
51    channel TEXT DEFAULT 'general',
52    project_path TEXT,
53    status TEXT DEFAULT 'active',
54    ended_at INTEGER,
55    created_at INTEGER NOT NULL,
56    updated_at INTEGER NOT NULL,
57    user_id TEXT,
58    synced_at INTEGER,
59    is_synced INTEGER DEFAULT 0
60);
61
62CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path);
63CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
64CREATE INDEX IF NOT EXISTS idx_sessions_project_status ON sessions(project_path, status);
65CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
66CREATE INDEX IF NOT EXISTS idx_sessions_synced ON sessions(is_synced, synced_at);
67
68-- Session Projects: Many-to-many for multi-path sessions
69CREATE TABLE IF NOT EXISTS session_projects (
70    session_id TEXT NOT NULL,
71    project_path TEXT NOT NULL,
72    added_at INTEGER NOT NULL,
73    PRIMARY KEY (session_id, project_path),
74    FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
75);
76
77CREATE INDEX IF NOT EXISTS idx_session_projects_path ON session_projects(project_path);
78CREATE INDEX IF NOT EXISTS idx_session_projects_session ON session_projects(session_id);
79
80-- Context Items: The actual saved context
81CREATE TABLE IF NOT EXISTS context_items (
82    id TEXT PRIMARY KEY,
83    session_id TEXT NOT NULL,
84    key TEXT NOT NULL,
85    value TEXT NOT NULL,
86    category TEXT DEFAULT 'note',
87    priority TEXT DEFAULT 'normal',
88    channel TEXT DEFAULT 'general',
89    tags TEXT DEFAULT '[]',
90    size INTEGER DEFAULT 0,
91    created_at INTEGER NOT NULL,
92    updated_at INTEGER NOT NULL,
93    embedding_status TEXT DEFAULT 'none',
94    embedding_provider TEXT,
95    embedding_model TEXT,
96    chunk_count INTEGER DEFAULT 0,
97    embedded_at INTEGER,
98    synced_at INTEGER,
99    is_synced INTEGER DEFAULT 0,
100    FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
101    UNIQUE(session_id, key)
102);
103
104CREATE INDEX IF NOT EXISTS idx_context_items_session ON context_items(session_id);
105CREATE INDEX IF NOT EXISTS idx_context_items_category ON context_items(category);
106CREATE INDEX IF NOT EXISTS idx_context_items_priority ON context_items(priority);
107CREATE INDEX IF NOT EXISTS idx_context_items_channel ON context_items(channel);
108CREATE INDEX IF NOT EXISTS idx_context_items_created ON context_items(created_at DESC);
109CREATE INDEX IF NOT EXISTS idx_context_items_session_created ON context_items(session_id, created_at DESC);
110CREATE INDEX IF NOT EXISTS idx_context_items_embedding_status ON context_items(embedding_status);
111CREATE INDEX IF NOT EXISTS idx_context_items_synced ON context_items(is_synced, synced_at);
112
113-- Checkpoints: Complete snapshots of session state
114CREATE TABLE IF NOT EXISTS checkpoints (
115    id TEXT PRIMARY KEY,
116    session_id TEXT NOT NULL,
117    name TEXT NOT NULL,
118    description TEXT,
119    git_status TEXT,
120    git_branch TEXT,
121    item_count INTEGER DEFAULT 0,
122    total_size INTEGER DEFAULT 0,
123    created_at INTEGER NOT NULL,
124    synced_at INTEGER,
125    is_synced INTEGER DEFAULT 0,
126    FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
127);
128
129CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id);
130CREATE INDEX IF NOT EXISTS idx_checkpoints_created ON checkpoints(created_at DESC);
131
132-- Checkpoint Items: Link checkpoints to context items
133CREATE TABLE IF NOT EXISTS checkpoint_items (
134    id TEXT PRIMARY KEY,
135    checkpoint_id TEXT NOT NULL,
136    context_item_id TEXT NOT NULL,
137    group_name TEXT,
138    group_order INTEGER DEFAULT 0,
139    FOREIGN KEY (checkpoint_id) REFERENCES checkpoints(id) ON DELETE CASCADE,
140    FOREIGN KEY (context_item_id) REFERENCES context_items(id) ON DELETE CASCADE
141);
142
143-- Plans: Implementation plans (PRDs/specs)
144CREATE TABLE IF NOT EXISTS plans (
145    id TEXT PRIMARY KEY,
146    short_id TEXT,
147    project_id TEXT NOT NULL,
148    project_path TEXT NOT NULL,
149    title TEXT NOT NULL,
150    content TEXT,
151    status TEXT DEFAULT 'draft',
152    success_criteria TEXT,
153    created_in_session TEXT,
154    completed_in_session TEXT,
155    created_at INTEGER NOT NULL,
156    updated_at INTEGER NOT NULL,
157    completed_at INTEGER,
158    FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
159);
160
161CREATE INDEX IF NOT EXISTS idx_plans_project_id ON plans(project_id);
162CREATE INDEX IF NOT EXISTS idx_plans_project_path ON plans(project_path);
163CREATE INDEX IF NOT EXISTS idx_plans_status ON plans(status);
164CREATE INDEX IF NOT EXISTS idx_plans_short_id ON plans(project_id, short_id);
165
166-- Issues: Task/bug/feature tracking
167-- Note: Parent-child relationships are stored in issue_dependencies with type 'parent-child'
168CREATE TABLE IF NOT EXISTS issues (
169    id TEXT PRIMARY KEY,
170    short_id TEXT,
171    project_path TEXT NOT NULL,
172    title TEXT NOT NULL,
173    description TEXT,
174    details TEXT,
175    status TEXT DEFAULT 'open',
176    priority INTEGER DEFAULT 2,
177    issue_type TEXT DEFAULT 'task',
178    plan_id TEXT,
179    created_by_agent TEXT,
180    closed_by_agent TEXT,
181    created_in_session TEXT,
182    closed_in_session TEXT,
183    assigned_to_agent TEXT,
184    assigned_at INTEGER,
185    assigned_in_session TEXT,
186    created_at INTEGER NOT NULL,
187    updated_at INTEGER NOT NULL,
188    closed_at INTEGER,
189    deferred_at INTEGER,
190    FOREIGN KEY (plan_id) REFERENCES plans(id) ON DELETE SET NULL,
191    CHECK (priority >= 0 AND priority <= 4)
192);
193
194CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_path);
195CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
196CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues(priority);
197CREATE INDEX IF NOT EXISTS idx_issues_type ON issues(issue_type);
198CREATE INDEX IF NOT EXISTS idx_issues_plan ON issues(plan_id);
199CREATE INDEX IF NOT EXISTS idx_issues_short_id ON issues(project_path, short_id);
200CREATE INDEX IF NOT EXISTS idx_issues_assigned ON issues(assigned_to_agent);
201
202-- Issue Projects: Many-to-many for multi-project issues
203CREATE TABLE IF NOT EXISTS issue_projects (
204    issue_id TEXT NOT NULL,
205    project_path TEXT NOT NULL,
206    added_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
207    PRIMARY KEY (issue_id, project_path),
208    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
209);
210
211CREATE INDEX IF NOT EXISTS idx_issue_projects_path ON issue_projects(project_path);
212CREATE INDEX IF NOT EXISTS idx_issue_projects_issue ON issue_projects(issue_id);
213
214-- Issue Labels: Tags for categorizing issues
215CREATE TABLE IF NOT EXISTS issue_labels (
216    id TEXT PRIMARY KEY,
217    issue_id TEXT NOT NULL,
218    label TEXT NOT NULL,
219    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
220    UNIQUE(issue_id, label)
221);
222
223CREATE INDEX IF NOT EXISTS idx_issue_labels_label ON issue_labels(label);
224
225-- Issue Dependencies: Relationships between issues
226CREATE TABLE IF NOT EXISTS issue_dependencies (
227    id TEXT PRIMARY KEY,
228    issue_id TEXT NOT NULL,
229    depends_on_id TEXT NOT NULL,
230    dependency_type TEXT DEFAULT 'blocks',
231    created_at INTEGER NOT NULL,
232    FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
233    FOREIGN KEY (depends_on_id) REFERENCES issues(id) ON DELETE CASCADE,
234    UNIQUE(issue_id, depends_on_id)
235);
236
237CREATE INDEX IF NOT EXISTS idx_issue_deps_depends ON issue_dependencies(depends_on_id);
238
239-- Project Memory: Store project-specific commands, configs, notes
240CREATE TABLE IF NOT EXISTS project_memory (
241    id TEXT PRIMARY KEY,
242    project_path TEXT NOT NULL,
243    key TEXT NOT NULL,
244    value TEXT NOT NULL,
245    category TEXT DEFAULT 'command',
246    created_at INTEGER NOT NULL,
247    updated_at INTEGER NOT NULL,
248    UNIQUE(project_path, key)
249);
250
251CREATE INDEX IF NOT EXISTS idx_memory_project ON project_memory(project_path);
252CREATE INDEX IF NOT EXISTS idx_memory_category ON project_memory(category);
253
254-- Agent Sessions: Track active agents per session
255CREATE TABLE IF NOT EXISTS agent_sessions (
256    agent_id TEXT PRIMARY KEY,
257    session_id TEXT NOT NULL,
258    project_path TEXT NOT NULL,
259    git_branch TEXT,
260    provider TEXT,
261    last_active_at INTEGER NOT NULL,
262    FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
263);
264
265CREATE INDEX IF NOT EXISTS idx_agent_sessions_session ON agent_sessions(session_id);
266CREATE INDEX IF NOT EXISTS idx_agent_sessions_project ON agent_sessions(project_path);
267CREATE INDEX IF NOT EXISTS idx_agent_sessions_active ON agent_sessions(last_active_at DESC);
268
269-- File Cache: Track files read during session (optional)
270CREATE TABLE IF NOT EXISTS file_cache (
271    id TEXT PRIMARY KEY,
272    session_id TEXT NOT NULL,
273    file_path TEXT NOT NULL,
274    content TEXT,
275    hash TEXT,
276    size INTEGER DEFAULT 0,
277    last_read INTEGER NOT NULL,
278    updated_at INTEGER NOT NULL,
279    FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
280    UNIQUE(session_id, file_path)
281);
282
283CREATE INDEX IF NOT EXISTS idx_file_cache_session ON file_cache(session_id);
284CREATE INDEX IF NOT EXISTS idx_file_cache_path ON file_cache(file_path);
285
286-- ====================
287-- Sync Support (JSONL Export/Import)
288-- ====================
289
290-- Dirty tracking for incremental export
291CREATE TABLE IF NOT EXISTS dirty_sessions (
292    session_id TEXT PRIMARY KEY,
293    marked_at INTEGER NOT NULL
294);
295
296CREATE TABLE IF NOT EXISTS dirty_issues (
297    issue_id TEXT PRIMARY KEY,
298    marked_at INTEGER NOT NULL
299);
300
301CREATE TABLE IF NOT EXISTS dirty_context_items (
302    item_id TEXT PRIMARY KEY,
303    marked_at INTEGER NOT NULL
304);
305
306-- Export hashes for deduplication
307CREATE TABLE IF NOT EXISTS export_hashes (
308    entity_type TEXT NOT NULL,
309    entity_id TEXT NOT NULL,
310    content_hash TEXT NOT NULL,
311    exported_at INTEGER NOT NULL,
312    PRIMARY KEY (entity_type, entity_id)
313);
314
315-- Deletion tracking for sync
316-- Records when entities are deleted so imports can apply the deletion
317CREATE TABLE IF NOT EXISTS sync_deletions (
318    id INTEGER PRIMARY KEY AUTOINCREMENT,
319    entity_type TEXT NOT NULL,
320    entity_id TEXT NOT NULL,
321    project_path TEXT NOT NULL,
322    deleted_at INTEGER NOT NULL,
323    deleted_by TEXT NOT NULL,
324    exported INTEGER DEFAULT 0,
325    UNIQUE(entity_type, entity_id)
326);
327
328CREATE INDEX IF NOT EXISTS idx_sync_deletions_project ON sync_deletions(project_path);
329CREATE INDEX IF NOT EXISTS idx_sync_deletions_exported ON sync_deletions(exported);
330
331-- ====================
332-- Embeddings Support (BLOB-based, pure Rust)
333-- ====================
334
335-- Embeddings configuration metadata (for dynamic dimension support)
336CREATE TABLE IF NOT EXISTS embeddings_meta (
337    key TEXT PRIMARY KEY,
338    value TEXT NOT NULL,
339    updated_at INTEGER NOT NULL
340);
341
342-- Vector storage using regular SQLite BLOB columns
343-- Stores embeddings as binary f32 arrays (4 bytes per dimension)
344-- Supports chunking for large text items
345CREATE TABLE IF NOT EXISTS embedding_chunks (
346    id TEXT PRIMARY KEY,
347    item_id TEXT NOT NULL,
348    chunk_index INTEGER NOT NULL DEFAULT 0,
349    chunk_text TEXT NOT NULL,
350    embedding BLOB NOT NULL,
351    dimensions INTEGER NOT NULL,
352    provider TEXT NOT NULL,
353    model TEXT NOT NULL,
354    created_at INTEGER NOT NULL,
355    FOREIGN KEY (item_id) REFERENCES context_items(id) ON DELETE CASCADE,
356    UNIQUE(item_id, chunk_index)
357);
358
359CREATE INDEX IF NOT EXISTS idx_embedding_chunks_item ON embedding_chunks(item_id);
360CREATE INDEX IF NOT EXISTS idx_embedding_chunks_provider ON embedding_chunks(provider, model);
361
362-- ====================
363-- Audit Events
364-- ====================
365
366CREATE TABLE IF NOT EXISTS events (
367    id INTEGER PRIMARY KEY AUTOINCREMENT,
368    entity_type TEXT NOT NULL,
369    entity_id TEXT NOT NULL,
370    event_type TEXT NOT NULL,
371    actor TEXT NOT NULL,
372    old_value TEXT,
373    new_value TEXT,
374    comment TEXT,
375    created_at INTEGER NOT NULL
376);
377
378CREATE INDEX IF NOT EXISTS idx_events_entity ON events(entity_type, entity_id);
379CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
380CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);
381CREATE INDEX IF NOT EXISTS idx_events_actor ON events(actor);
382
383-- ====================
384-- Triggers
385-- ====================
386
387-- Auto-update session timestamps when context items change
388CREATE TRIGGER IF NOT EXISTS update_session_timestamp
389AFTER INSERT ON context_items
390BEGIN
391    UPDATE sessions
392    SET updated_at = (strftime('%s', 'now') * 1000)
393    WHERE id = NEW.session_id;
394END;
395
396-- Mark sessions dirty on change
397CREATE TRIGGER IF NOT EXISTS mark_session_dirty_insert
398AFTER INSERT ON sessions
399BEGIN
400    INSERT INTO dirty_sessions (session_id, marked_at)
401    VALUES (NEW.id, strftime('%s', 'now') * 1000)
402    ON CONFLICT(session_id) DO UPDATE SET marked_at = excluded.marked_at;
403END;
404
405CREATE TRIGGER IF NOT EXISTS mark_session_dirty_update
406AFTER UPDATE ON sessions
407BEGIN
408    INSERT INTO dirty_sessions (session_id, marked_at)
409    VALUES (NEW.id, strftime('%s', 'now') * 1000)
410    ON CONFLICT(session_id) DO UPDATE SET marked_at = excluded.marked_at;
411END;
412
413-- Mark issues dirty on change
414CREATE TRIGGER IF NOT EXISTS mark_issue_dirty_insert
415AFTER INSERT ON issues
416BEGIN
417    INSERT INTO dirty_issues (issue_id, marked_at)
418    VALUES (NEW.id, strftime('%s', 'now') * 1000)
419    ON CONFLICT(issue_id) DO UPDATE SET marked_at = excluded.marked_at;
420END;
421
422CREATE TRIGGER IF NOT EXISTS mark_issue_dirty_update
423AFTER UPDATE ON issues
424BEGIN
425    INSERT INTO dirty_issues (issue_id, marked_at)
426    VALUES (NEW.id, strftime('%s', 'now') * 1000)
427    ON CONFLICT(issue_id) DO UPDATE SET marked_at = excluded.marked_at;
428END;
429
430-- Mark context items dirty on change
431-- Note: We use a single INSERT ON CONFLICT UPDATE pattern to handle both insert and update
432CREATE TRIGGER IF NOT EXISTS mark_item_dirty_insert
433AFTER INSERT ON context_items
434BEGIN
435    INSERT INTO dirty_context_items (item_id, marked_at)
436    VALUES (NEW.id, strftime('%s', 'now') * 1000)
437    ON CONFLICT(item_id) DO UPDATE SET marked_at = excluded.marked_at;
438END;
439
440CREATE TRIGGER IF NOT EXISTS mark_item_dirty_update
441AFTER UPDATE ON context_items
442BEGIN
443    INSERT INTO dirty_context_items (item_id, marked_at)
444    VALUES (NEW.id, strftime('%s', 'now') * 1000)
445    ON CONFLICT(item_id) DO UPDATE SET marked_at = excluded.marked_at;
446END;
447
448-- ====================
449-- Views
450-- ====================
451
452-- Recent sessions with item counts
453CREATE VIEW IF NOT EXISTS recent_sessions AS
454SELECT
455    s.id,
456    s.name,
457    s.description,
458    s.branch,
459    s.channel,
460    s.status,
461    s.project_path,
462    s.created_at,
463    s.updated_at,
464    COUNT(DISTINCT ci.id) as item_count,
465    COALESCE(SUM(ci.size), 0) as total_size
466FROM sessions s
467LEFT JOIN context_items ci ON s.id = ci.session_id
468GROUP BY s.id
469ORDER BY s.updated_at DESC;
470
471-- High priority items
472CREATE VIEW IF NOT EXISTS high_priority_items AS
473SELECT
474    ci.*,
475    s.name as session_name,
476    s.branch as session_branch
477FROM context_items ci
478JOIN sessions s ON ci.session_id = s.id
479WHERE ci.priority = 'high'
480ORDER BY ci.created_at DESC;
481
482-- Session summary with category breakdown
483CREATE VIEW IF NOT EXISTS session_summary AS
484SELECT
485    s.id,
486    s.name,
487    s.channel,
488    s.status,
489    COUNT(DISTINCT ci.id) as total_items,
490    COUNT(DISTINCT CASE WHEN ci.category = 'reminder' THEN ci.id END) as reminders,
491    COUNT(DISTINCT CASE WHEN ci.category = 'decision' THEN ci.id END) as decisions,
492    COUNT(DISTINCT CASE WHEN ci.category = 'progress' THEN ci.id END) as progress_items,
493    COUNT(DISTINCT CASE WHEN ci.priority = 'high' THEN ci.id END) as high_priority,
494    COUNT(DISTINCT cp.id) as checkpoint_count,
495    COALESCE(SUM(ci.size), 0) as total_size
496FROM sessions s
497LEFT JOIN context_items ci ON s.id = ci.session_id
498LEFT JOIN checkpoints cp ON s.id = cp.session_id
499GROUP BY s.id;
500
501-- Open issues by project
502CREATE VIEW IF NOT EXISTS open_issues AS
503SELECT
504    i.*,
505    COUNT(DISTINCT il.label) as label_count,
506    COUNT(DISTINCT id.depends_on_id) as dependency_count
507FROM issues i
508LEFT JOIN issue_labels il ON i.id = il.issue_id
509LEFT JOIN issue_dependencies id ON i.id = id.issue_id
510WHERE i.status NOT IN ('closed', 'deferred')
511GROUP BY i.id
512ORDER BY i.priority DESC, i.created_at ASC;
513"#;
514
515/// Apply the schema to the database.
516///
517/// This uses `execute_batch` to run the entire DDL script.
518/// It is idempotent because all statements use `IF NOT EXISTS`.
519///
520/// # Errors
521///
522/// Returns an error if the SQL execution fails or pragmas cannot be set.
523pub fn apply_schema(conn: &Connection) -> Result<()> {
524    // Set pragmas before schema creation
525    conn.pragma_update(None, "journal_mode", "WAL")?;
526    conn.pragma_update(None, "foreign_keys", "ON")?;
527    conn.pragma_update(None, "synchronous", "NORMAL")?;
528    conn.pragma_update(None, "cache_size", "-64000")?; // 64MB cache
529    conn.pragma_update(None, "temp_store", "MEMORY")?;
530
531    // Apply schema
532    conn.execute_batch(SCHEMA_SQL)?;
533
534    // Run migrations for existing databases
535    super::migrations::run_migrations(conn)?;
536
537    // Record schema version
538    conn.execute(
539        "INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?1, ?2)",
540        rusqlite::params![
541            format!("v{CURRENT_SCHEMA_VERSION}"),
542            chrono::Utc::now().timestamp_millis()
543        ],
544    )?;
545
546    Ok(())
547}
548
549/// Check if a column exists in a table.
550#[allow(dead_code)]
551fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
552    let sql = format!(
553        "SELECT 1 FROM pragma_table_info('{}') WHERE name = ?1",
554        table
555    );
556    conn.prepare(&sql)?.exists([column])
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn test_apply_schema() {
565        let conn = Connection::open_in_memory().unwrap();
566        apply_schema(&conn).expect("Failed to apply schema");
567
568        // Verify core tables exist
569        let tables: Vec<String> = conn
570            .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
571            .unwrap()
572            .query_map([], |row| row.get(0))
573            .unwrap()
574            .collect::<Result<Vec<_>, _>>()
575            .unwrap();
576
577        assert!(tables.contains(&"sessions".to_string()));
578        assert!(tables.contains(&"context_items".to_string()));
579        assert!(tables.contains(&"issues".to_string()));
580        assert!(tables.contains(&"checkpoints".to_string()));
581        assert!(tables.contains(&"plans".to_string()));
582        assert!(tables.contains(&"projects".to_string()));
583        assert!(tables.contains(&"project_memory".to_string()));
584        assert!(tables.contains(&"dirty_sessions".to_string()));
585        assert!(tables.contains(&"events".to_string()));
586        assert!(tables.contains(&"embedding_chunks".to_string()));
587        assert!(tables.contains(&"embeddings_meta".to_string()));
588    }
589
590    #[test]
591    fn test_schema_is_idempotent() {
592        let conn = Connection::open_in_memory().unwrap();
593
594        // Apply twice - should not fail
595        apply_schema(&conn).expect("First apply failed");
596        apply_schema(&conn).expect("Second apply failed");
597    }
598
599    #[test]
600    fn test_foreign_keys_enabled() {
601        let conn = Connection::open_in_memory().unwrap();
602        apply_schema(&conn).unwrap();
603
604        let fk_enabled: i32 = conn
605            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
606            .unwrap();
607        assert_eq!(fk_enabled, 1);
608    }
609
610    #[test]
611    fn test_priority_constraint() {
612        let conn = Connection::open_in_memory().unwrap();
613        apply_schema(&conn).unwrap();
614
615        // Valid priority (0-4)
616        let result = conn.execute(
617            "INSERT INTO issues (id, project_path, title, priority, created_at, updated_at)
618             VALUES ('test1', '/test', 'Test', 2, 0, 0)",
619            [],
620        );
621        assert!(result.is_ok());
622
623        // Invalid priority (5)
624        let result = conn.execute(
625            "INSERT INTO issues (id, project_path, title, priority, created_at, updated_at)
626             VALUES ('test2', '/test', 'Test', 5, 0, 0)",
627            [],
628        );
629        assert!(result.is_err());
630    }
631
632    #[test]
633    fn test_dirty_tracking_triggers() {
634        let conn = Connection::open_in_memory().unwrap();
635        apply_schema(&conn).unwrap();
636
637        // Insert a session
638        conn.execute(
639            "INSERT INTO sessions (id, name, created_at, updated_at)
640             VALUES ('sess1', 'Test Session', 0, 0)",
641            [],
642        )
643        .unwrap();
644
645        // Check it was marked dirty
646        let dirty_count: i32 = conn
647            .query_row(
648                "SELECT COUNT(*) FROM dirty_sessions WHERE session_id = 'sess1'",
649                [],
650                |row| row.get(0),
651            )
652            .unwrap();
653        assert_eq!(dirty_count, 1);
654    }
655}