1use rusqlite::Connection;
2
3use crate::entry::ContextEntry;
4use crate::Result;
5
6const CREATE_SCHEMA_VERSION: &str =
7 "CREATE TABLE IF NOT EXISTS schema_version (id INTEGER PRIMARY KEY CHECK(id = 1), version INTEGER NOT NULL)";
8
9pub(crate) fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<ContextEntry> {
14 let token_count: Option<i64> = row.get("token_count")?;
15 let metadata_str: Option<String> = row.get("metadata")?;
16 let metadata = metadata_str
17 .map(|s| serde_json::from_str(&s))
18 .transpose()
19 .map_err(|e| {
20 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
21 })?;
22
23 let token_count = token_count.map(usize::try_from).transpose().map_err(|e| {
24 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Integer, Box::new(e))
25 })?;
26
27 Ok(ContextEntry {
28 id: row.get("id")?,
29 content: row.get("content")?,
30 timestamp: row.get("timestamp")?,
31 kind: row.get("kind")?,
32 scope: row.get("scope")?,
33 session_id: row.get("session_id")?,
34 token_count,
35 metadata,
36 })
37}
38
39pub(crate) const SCHEMA_V1: &str = r"
40CREATE TABLE IF NOT EXISTS entries (
41 id TEXT PRIMARY KEY,
42 content TEXT NOT NULL,
43 timestamp INTEGER NOT NULL,
44 kind TEXT NOT NULL CHECK(kind IN ('Manual','PreCompact','Auto')),
45 token_count INTEGER CHECK(token_count >= 0),
46 created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER))
47) STRICT;
48
49CREATE INDEX IF NOT EXISTS idx_entries_timestamp ON entries(timestamp);
50
51CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
52 content,
53 content=entries,
54 content_rowid=rowid
55);
56
57CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
58 INSERT INTO entries_fts(rowid, content) VALUES (new.rowid, new.content);
59END;
60
61CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
62 INSERT INTO entries_fts(entries_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
63END;
64
65CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
66 INSERT INTO entries_fts(entries_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
67 INSERT INTO entries_fts(rowid, content) VALUES (new.rowid, new.content);
68END;
69";
70
71pub(crate) const SCHEMA_V2: &str = r"
72BEGIN IMMEDIATE;
73
74ALTER TABLE entries ADD COLUMN session_id TEXT;
75ALTER TABLE entries ADD COLUMN compaction_count INTEGER;
76ALTER TABLE entries ADD COLUMN compaction_trigger TEXT;
77ALTER TABLE entries ADD COLUMN runtime TEXT;
78ALTER TABLE entries ADD COLUMN model TEXT;
79ALTER TABLE entries ADD COLUMN cwd TEXT;
80ALTER TABLE entries ADD COLUMN git_branch TEXT;
81ALTER TABLE entries ADD COLUMN git_sha TEXT;
82ALTER TABLE entries ADD COLUMN turn_id TEXT;
83ALTER TABLE entries ADD COLUMN agent_type TEXT;
84ALTER TABLE entries ADD COLUMN agent_id TEXT;
85ALTER TABLE entries ADD COLUMN embedding BLOB;
86
87CREATE INDEX IF NOT EXISTS idx_entries_session_id ON entries(session_id);
88CREATE INDEX IF NOT EXISTS idx_entries_runtime ON entries(runtime);
89CREATE INDEX IF NOT EXISTS idx_entries_git_branch ON entries(git_branch);
90CREATE INDEX IF NOT EXISTS idx_embedding_present ON entries(id) WHERE embedding IS NOT NULL;
91
92CREATE TABLE IF NOT EXISTS runtime_configs (
93 runtime TEXT PRIMARY KEY,
94 display_name TEXT NOT NULL,
95 hook_format TEXT NOT NULL,
96 active INTEGER NOT NULL DEFAULT 1
97) STRICT;
98
99CREATE TABLE IF NOT EXISTS runtime_field_mappings (
100 runtime TEXT NOT NULL REFERENCES runtime_configs(runtime),
101 canonical_field TEXT NOT NULL,
102 source_path TEXT NOT NULL,
103 transform TEXT,
104 PRIMARY KEY (runtime, canonical_field)
105) STRICT;
106
107CREATE TABLE IF NOT EXISTS entry_metadata_raw (
108 entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
109 runtime TEXT NOT NULL,
110 raw_json TEXT NOT NULL,
111 PRIMARY KEY (entry_id)
112) STRICT;
113
114CREATE TABLE IF NOT EXISTS tags (
115 id TEXT PRIMARY KEY,
116 created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER))
117) STRICT;
118
119CREATE TABLE IF NOT EXISTS entry_tags (
120 entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
121 tag_id TEXT NOT NULL REFERENCES tags(id),
122 PRIMARY KEY (entry_id, tag_id)
123) STRICT;
124
125CREATE INDEX IF NOT EXISTS idx_entry_tags_tag_id ON entry_tags(tag_id);
126
127INSERT OR IGNORE INTO runtime_configs (runtime, display_name, hook_format, active) VALUES
128 ('claude-code', 'Claude Code', 'json_stdin', 1),
129 ('codex', 'Codex CLI', 'json_stdin', 1),
130 ('gemini', 'Gemini CLI', 'json_stdin', 1),
131 ('cline', 'Cline', 'json_stdin', 1),
132 ('openclaw', 'OpenClaw', 'json_stdin', 1);
133
134INSERT OR IGNORE INTO runtime_field_mappings (runtime, canonical_field, source_path, transform) VALUES
135 ('claude-code', 'session_id', 'session_id', NULL),
136 ('claude-code', 'model', 'model', NULL),
137 ('claude-code', 'cwd', 'cwd', NULL),
138 ('claude-code', 'compaction_trigger', 'matcher_value', NULL),
139 ('claude-code', 'agent_type', 'agent_type', NULL),
140 ('claude-code', 'agent_id', 'agent_id', NULL),
141 ('codex', 'session_id', 'threadId', NULL),
142 ('codex', 'turn_id', 'turnId', NULL),
143 ('codex', 'git_branch', 'git.branch', NULL),
144 ('codex', 'git_sha', 'git.sha', NULL),
145 ('gemini', 'session_id', 'session_id', NULL),
146 ('gemini', 'cwd', 'cwd', NULL),
147 ('cline', 'session_id', 'sessionId', NULL),
148 ('openclaw', 'session_id', 'sessionKey', NULL);
149
150INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, 2);
151COMMIT;
152";
153
154pub(crate) const SCHEMA_V3: &str = r"
155BEGIN IMMEDIATE;
156
157CREATE TABLE entries_v3 (
158 id TEXT PRIMARY KEY,
159 content TEXT NOT NULL,
160 timestamp INTEGER NOT NULL,
161 kind TEXT NOT NULL,
162 scope TEXT,
163 session_id TEXT,
164 token_count INTEGER CHECK(token_count >= 0),
165 metadata TEXT,
166 created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER))
167) STRICT;
168
169INSERT INTO entries_v3 (id, content, timestamp, kind, scope, session_id, token_count, metadata, created_at)
170SELECT id, content, timestamp,
171 CASE kind WHEN 'Manual' THEN 'manual' WHEN 'PreCompact' THEN 'snapshot' WHEN 'Auto' THEN 'summary' ELSE lower(kind) END,
172 NULL,
173 session_id, token_count,
174 json_patch('{}', json_object(
175 'runtime', runtime, 'model', model, 'cwd', cwd,
176 'git_branch', git_branch, 'git_sha', git_sha,
177 'compaction_trigger', compaction_trigger,
178 'turn_id', turn_id, 'agent_type', agent_type, 'agent_id', agent_id)),
179 created_at
180FROM entries;
181
182DROP TRIGGER IF EXISTS entries_ai;
183DROP TRIGGER IF EXISTS entries_ad;
184DROP TRIGGER IF EXISTS entries_au;
185DROP TABLE entries_fts;
186DROP TABLE entries;
187ALTER TABLE entries_v3 RENAME TO entries;
188
189CREATE INDEX IF NOT EXISTS idx_entries_timestamp ON entries(timestamp);
190CREATE INDEX IF NOT EXISTS idx_entries_scope ON entries(scope);
191CREATE INDEX IF NOT EXISTS idx_entries_session_id ON entries(session_id);
192
193CREATE VIRTUAL TABLE entries_fts USING fts5(content, content=entries, content_rowid=rowid);
194INSERT INTO entries_fts(rowid, content) SELECT rowid, content FROM entries;
195
196CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
197 INSERT INTO entries_fts(rowid, content) VALUES (new.rowid, new.content);
198END;
199
200CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
201 INSERT INTO entries_fts(entries_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
202END;
203
204CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
205 INSERT INTO entries_fts(entries_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
206 INSERT INTO entries_fts(rowid, content) VALUES (new.rowid, new.content);
207END;
208
209DROP TABLE IF EXISTS runtime_field_mappings;
210DROP TABLE IF EXISTS runtime_configs;
211DROP TABLE IF EXISTS entry_metadata_raw;
212
213INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, 3);
214COMMIT;
215";
216
217pub fn migrate(conn: &Connection) -> Result<()> {
226 conn.execute_batch(CREATE_SCHEMA_VERSION)?;
227
228 let version: i64 = conn.query_row(
229 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
230 [],
231 |row| row.get(0),
232 )?;
233
234 if version < 1 {
235 conn.execute_batch(SCHEMA_V1)?;
236
237 conn.execute(
238 "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, 1)",
239 [],
240 )?;
241 }
242
243 if version < 2 {
244 conn.execute_batch(SCHEMA_V2)?;
245 }
246
247 if version < 3 {
248 conn.execute_batch(SCHEMA_V3)?;
249 }
250
251 Ok(())
252}