1use rusqlite::{Connection, Result};
7
8pub const CURRENT_SCHEMA_VERSION: i32 = 1;
10
11pub 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
515pub fn apply_schema(conn: &Connection) -> Result<()> {
524 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")?; conn.pragma_update(None, "temp_store", "MEMORY")?;
530
531 conn.execute_batch(SCHEMA_SQL)?;
533
534 super::migrations::run_migrations(conn)?;
536
537 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#[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 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_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 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 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 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 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}