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
113const V6_UP: &str = include_str!("../sql/006-brain-retune-driver.sql");
114
115const V7_UP: &str = include_str!("../sql/007-notes-seq.sql");
116
117const V8_UP: &str = include_str!("../sql/008-notes-seq-repair.sql");
118
119const V9_UP: &str = include_str!("../sql/009-entities-name-ci-index.sql");
120
121const V10_UP: &str = include_str!("../sql/010-entities-content-ref.sql");
122
123const V11_UP: &str = include_str!("../sql/011-ann-write-log.sql");
124
125const V12_UP: &str = include_str!("../sql/012-ann-write-log-model-seq-index.sql");
126
127pub const ANN_WRITE_LOG_DDL: &str = V11_UP;
135
136pub const ANN_WRITE_LOG_MODEL_SEQ_INDEX_DDL: &str = V12_UP;
141
142pub const EMBEDDING_MODELS_DDL: &str = include_str!("../sql/embedding-models-ddl.sql");
148
149pub const MIGRATIONS: &[VersionedMigration] = &[
151 VersionedMigration {
152 version: 1,
153 name: "initial_schema",
154 up: V1_UP,
155 },
156 VersionedMigration {
157 version: 2,
158 name: "narrow_fts_sections_update_trigger",
159 up: V2_UP,
160 },
161 VersionedMigration {
162 version: 3,
163 name: "backfill_domain_mirror_atoms",
164 up: V3_UP,
165 },
166 VersionedMigration {
167 version: 4,
168 name: "fts_consolidation",
169 up: V4_UP,
170 },
171 VersionedMigration {
172 version: 5,
173 name: "unique_comm_message_external_id",
174 up: V5_UP,
175 },
176 VersionedMigration {
177 version: 6,
178 name: "brain_retune_driver",
179 up: V6_UP,
180 },
181 VersionedMigration {
182 version: 7,
183 name: "notes_seq",
184 up: V7_UP,
185 },
186 VersionedMigration {
187 version: 8,
188 name: "notes_seq_repair",
189 up: V8_UP,
190 },
191 VersionedMigration {
192 version: 9,
193 name: "entities_name_ci_index",
194 up: V9_UP,
195 },
196 VersionedMigration {
197 version: 10,
198 name: "entities_content_ref",
199 up: V10_UP,
200 },
201 VersionedMigration {
202 version: 11,
203 name: "ann_write_log",
204 up: V11_UP,
205 },
206 VersionedMigration {
207 version: 12,
208 name: "ann_write_log_model_seq_index",
209 up: V12_UP,
210 },
211];
212
213const MIGRATION_TRACKING_TABLE: &str = include_str!("../sql/schema-migrations-table.sql");
214
215pub fn read_schema_version(conn: &Connection) -> u32 {
221 conn.query_row(
222 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
223 [],
224 |row| row.get(0),
225 )
226 .unwrap_or(0)
227}
228
229pub fn inspect_schema_version(path: &std::path::Path) -> Result<u32, SqliteError> {
234 let conn = Connection::open_with_flags(
235 path,
236 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
237 )?;
238 Ok(read_schema_version(&conn))
239}
240
241pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
242 conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
243
244 let current_version: u32 = conn
245 .query_row(
246 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
247 [],
248 |row| row.get(0),
249 )
250 .unwrap_or(0);
251
252 let latest_version = MIGRATIONS.last().map(|m| m.version).unwrap_or(0);
258 if current_version > latest_version {
259 return Err(SqliteError::InvalidData(format!(
260 "database schema version {current_version} is ahead of the latest known migration \
261 {latest_version}. This database predates the consolidated baseline (ADR-015) or was \
262 written by a newer build. Recreate it from the current schema; in-place downgrade is \
263 not supported."
264 )));
265 }
266
267 let mut applied_version = current_version;
268
269 for migration in MIGRATIONS {
270 if migration.version <= current_version {
271 continue;
272 }
273
274 let tx = conn.transaction().map_err(|e| SqliteError::Migration {
275 version: migration.version,
276 error: e.to_string(),
277 })?;
278
279 tx.execute_batch(migration.up)
280 .map_err(|e| SqliteError::Migration {
281 version: migration.version,
282 error: e.to_string(),
283 })?;
284
285 let now = chrono::Utc::now().timestamp_micros();
286 tx.execute(
287 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
288 rusqlite::params![migration.version, migration.name, now],
289 )
290 .map_err(|e| SqliteError::Migration {
291 version: migration.version,
292 error: e.to_string(),
293 })?;
294
295 tx.commit().map_err(|e| SqliteError::Migration {
296 version: migration.version,
297 error: e.to_string(),
298 })?;
299
300 applied_version = migration.version;
301 }
302
303 Ok(applied_version)
304}
305
306#[derive(Debug)]
307pub struct EmbeddingModelRegistryRecord {
308 pub engine_name: String,
310 pub model_id: String,
312 pub key_version: String,
314 pub dimensions: u32,
316 pub status: String,
318 pub activated_at: Option<i64>,
320 pub superseded_at: Option<i64>,
322}
323
324pub fn query_embedding_models(
330 db: Option<&std::path::Path>,
331 engine_filter: Option<&str>,
332) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
333 let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| {
334 std::env::var("HOME")
335 .map(std::path::PathBuf::from)
336 .unwrap_or_else(|_| std::path::PathBuf::from("."))
337 .join(".khive/khive.db")
338 });
339 if !path.exists() {
340 return Ok(Vec::new());
341 }
342 let conn = Connection::open(path)?;
343 query_embedding_models_conn(&conn, engine_filter)
344}
345
346pub(crate) fn query_embedding_models_conn(
350 conn: &Connection,
351 engine_filter: Option<&str>,
352) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
353 let exists: bool = conn.query_row(
354 "SELECT COUNT(*) > 0 FROM sqlite_master \
355 WHERE type='table' AND name='_embedding_models'",
356 [],
357 |row| row.get(0),
358 )?;
359 if !exists {
360 return Ok(Vec::new());
361 }
362
363 let sql = if engine_filter.is_some() {
364 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
365 FROM _embedding_models WHERE engine_name = ?1 \
366 ORDER BY engine_name, activated_at IS NULL, activated_at"
367 } else {
368 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
369 FROM _embedding_models \
370 ORDER BY engine_name, activated_at IS NULL, activated_at"
371 };
372 let mut stmt = conn.prepare(sql)?;
373 let map_row = |row: &rusqlite::Row<'_>| {
374 let dim_raw: i64 = row.get(3)?;
375 let dimensions = u32::try_from(dim_raw).map_err(|_| {
376 rusqlite::Error::FromSqlConversionFailure(
377 3,
378 rusqlite::types::Type::Integer,
379 Box::new(std::io::Error::other(format!(
380 "_embedding_models.dim value {dim_raw} is outside the valid u32 range [0, {}]",
381 u32::MAX,
382 ))),
383 )
384 })?;
385 Ok(EmbeddingModelRegistryRecord {
386 engine_name: row.get(0)?,
387 model_id: row.get(1)?,
388 key_version: row.get(2)?,
389 dimensions,
390 status: row.get(4)?,
391 activated_at: row.get(5)?,
392 superseded_at: row.get(6)?,
393 })
394 };
395
396 if let Some(engine) = engine_filter {
397 stmt.query_map([engine], map_row)?
398 .collect::<Result<Vec<_>, _>>()
399 .map_err(Into::into)
400 } else {
401 stmt.query_map([], map_row)?
402 .collect::<Result<Vec<_>, _>>()
403 .map_err(Into::into)
404 }
405}
406
407#[cfg(test)]
412#[path = "migrations_tests.rs"]
413mod tests;