1use rusqlite::Connection;
10
11use crate::error::SqliteError;
12
13pub struct Migration {
19 pub id: &'static str,
21 pub up_sql: &'static str,
23 pub down_sql: Option<&'static str>,
25 pub is_already_applied: Option<fn(&Connection) -> bool>,
28}
29
30pub struct ServiceSchemaPlan {
32 pub service: &'static str,
34 pub sqlite: &'static [Migration],
36 pub postgres: &'static [Migration],
38}
39
40const SCHEMA_VERSION_TABLE: &str = include_str!("../sql/schema-version-table.sql");
41
42pub fn apply_schema_plan(conn: &Connection, plan: &ServiceSchemaPlan) -> Result<(), SqliteError> {
44 conn.execute_batch(SCHEMA_VERSION_TABLE)?;
45
46 for migration in plan.sqlite {
47 if let Some(check) = migration.is_already_applied {
49 if check(conn) {
50 continue;
51 }
52 }
53
54 let already: bool = conn.query_row(
56 "SELECT COUNT(*) > 0 FROM _schema_versions WHERE service = ?1 AND migration_id = ?2",
57 rusqlite::params![plan.service, migration.id],
58 |row| row.get(0),
59 )?;
60
61 if already {
62 continue;
63 }
64
65 conn.execute_batch(migration.up_sql)?;
67
68 conn.execute(
70 "INSERT INTO _schema_versions (service, migration_id, applied_at) VALUES (?1, ?2, ?3)",
71 rusqlite::params![
72 plan.service,
73 migration.id,
74 chrono::Utc::now().timestamp_micros(),
75 ],
76 )?;
77 }
78
79 Ok(())
80}
81
82pub struct VersionedMigration {
92 pub version: u32,
94 pub name: &'static str,
96 pub up: &'static str,
99}
100
101const V1_UP: &str = include_str!("../sql/schema.sql");
104
105const V2_UP: &str = include_str!("../sql/002-narrow-fts-sections-update-trigger.sql");
106
107const V3_UP: &str = include_str!("../sql/003-backfill-domain-mirror-atoms.sql");
108
109const V4_UP: &str = include_str!("../sql/004-fts-consolidation.sql");
110
111const V5_UP: &str = include_str!("../sql/005-unique-comm-external-id.sql");
112
113pub const EMBEDDING_MODELS_DDL: &str = include_str!("../sql/embedding-models-ddl.sql");
119
120pub const MIGRATIONS: &[VersionedMigration] = &[
122 VersionedMigration {
123 version: 1,
124 name: "initial_schema",
125 up: V1_UP,
126 },
127 VersionedMigration {
128 version: 2,
129 name: "narrow_fts_sections_update_trigger",
130 up: V2_UP,
131 },
132 VersionedMigration {
133 version: 3,
134 name: "backfill_domain_mirror_atoms",
135 up: V3_UP,
136 },
137 VersionedMigration {
138 version: 4,
139 name: "fts_consolidation",
140 up: V4_UP,
141 },
142 VersionedMigration {
143 version: 5,
144 name: "unique_comm_message_external_id",
145 up: V5_UP,
146 },
147];
148
149const MIGRATION_TRACKING_TABLE: &str = include_str!("../sql/schema-migrations-table.sql");
150
151pub fn read_schema_version(conn: &Connection) -> u32 {
157 conn.query_row(
158 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
159 [],
160 |row| row.get(0),
161 )
162 .unwrap_or(0)
163}
164
165pub fn inspect_schema_version(path: &std::path::Path) -> Result<u32, SqliteError> {
170 let conn = Connection::open_with_flags(
171 path,
172 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
173 )?;
174 Ok(read_schema_version(&conn))
175}
176
177pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
178 conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
179
180 let current_version: u32 = conn
181 .query_row(
182 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
183 [],
184 |row| row.get(0),
185 )
186 .unwrap_or(0);
187
188 let latest_version = MIGRATIONS.last().map(|m| m.version).unwrap_or(0);
194 if current_version > latest_version {
195 return Err(SqliteError::InvalidData(format!(
196 "database schema version {current_version} is ahead of the latest known migration \
197 {latest_version}. This database predates the consolidated baseline (ADR-015) or was \
198 written by a newer build. Recreate it from the current schema; in-place downgrade is \
199 not supported."
200 )));
201 }
202
203 let mut applied_version = current_version;
204
205 for migration in MIGRATIONS {
206 if migration.version <= current_version {
207 continue;
208 }
209
210 let tx = conn.transaction().map_err(|e| SqliteError::Migration {
211 version: migration.version,
212 error: e.to_string(),
213 })?;
214
215 tx.execute_batch(migration.up)
216 .map_err(|e| SqliteError::Migration {
217 version: migration.version,
218 error: e.to_string(),
219 })?;
220
221 let now = chrono::Utc::now().timestamp_micros();
222 tx.execute(
223 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
224 rusqlite::params![migration.version, migration.name, now],
225 )
226 .map_err(|e| SqliteError::Migration {
227 version: migration.version,
228 error: e.to_string(),
229 })?;
230
231 tx.commit().map_err(|e| SqliteError::Migration {
232 version: migration.version,
233 error: e.to_string(),
234 })?;
235
236 applied_version = migration.version;
237 }
238
239 Ok(applied_version)
240}
241
242#[derive(Debug)]
243pub struct EmbeddingModelRegistryRecord {
244 pub engine_name: String,
246 pub model_id: String,
248 pub key_version: String,
250 pub dimensions: u32,
252 pub status: String,
254 pub activated_at: Option<i64>,
256 pub superseded_at: Option<i64>,
258}
259
260pub fn query_embedding_models(
266 db: Option<&std::path::Path>,
267 engine_filter: Option<&str>,
268) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
269 let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| {
270 std::env::var("HOME")
271 .map(std::path::PathBuf::from)
272 .unwrap_or_else(|_| std::path::PathBuf::from("."))
273 .join(".khive/khive.db")
274 });
275 if !path.exists() {
276 return Ok(Vec::new());
277 }
278 let conn = Connection::open(path)?;
279 query_embedding_models_conn(&conn, engine_filter)
280}
281
282pub(crate) fn query_embedding_models_conn(
286 conn: &Connection,
287 engine_filter: Option<&str>,
288) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
289 let exists: bool = conn.query_row(
290 "SELECT COUNT(*) > 0 FROM sqlite_master \
291 WHERE type='table' AND name='_embedding_models'",
292 [],
293 |row| row.get(0),
294 )?;
295 if !exists {
296 return Ok(Vec::new());
297 }
298
299 let sql = if engine_filter.is_some() {
300 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
301 FROM _embedding_models WHERE engine_name = ?1 \
302 ORDER BY engine_name, activated_at IS NULL, activated_at"
303 } else {
304 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
305 FROM _embedding_models \
306 ORDER BY engine_name, activated_at IS NULL, activated_at"
307 };
308 let mut stmt = conn.prepare(sql)?;
309 let map_row = |row: &rusqlite::Row<'_>| {
310 let dim_raw: i64 = row.get(3)?;
311 let dimensions = u32::try_from(dim_raw).map_err(|_| {
312 rusqlite::Error::FromSqlConversionFailure(
313 3,
314 rusqlite::types::Type::Integer,
315 Box::new(std::io::Error::other(format!(
316 "_embedding_models.dim value {dim_raw} is outside the valid u32 range [0, {}]",
317 u32::MAX,
318 ))),
319 )
320 })?;
321 Ok(EmbeddingModelRegistryRecord {
322 engine_name: row.get(0)?,
323 model_id: row.get(1)?,
324 key_version: row.get(2)?,
325 dimensions,
326 status: row.get(4)?,
327 activated_at: row.get(5)?,
328 superseded_at: row.get(6)?,
329 })
330 };
331
332 if let Some(engine) = engine_filter {
333 stmt.query_map([engine], map_row)?
334 .collect::<Result<Vec<_>, _>>()
335 .map_err(Into::into)
336 } else {
337 stmt.query_map([], map_row)?
338 .collect::<Result<Vec<_>, _>>()
339 .map_err(Into::into)
340 }
341}
342
343#[cfg(test)]
348#[path = "migrations_tests.rs"]
349mod tests;