Skip to main content

context_forge/storage/
schema.rs

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
9/// Map a `rusqlite::Row` to a `ContextEntry`.
10///
11/// The row must contain all columns by name: id, content, timestamp, kind,
12/// `scope`, `session_id`, `token_count`, metadata.
13pub(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
217/// Run database migrations up to the latest schema version.
218///
219/// This function is idempotent — calling it multiple times on the same
220/// database has no additional effect once the schema is current.
221///
222/// # Errors
223///
224/// Returns an error if any migration statement fails to execute.
225pub 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}