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
123pub const EMBEDDING_MODELS_DDL: &str = include_str!("../sql/embedding-models-ddl.sql");
129
130pub const MIGRATIONS: &[VersionedMigration] = &[
132 VersionedMigration {
133 version: 1,
134 name: "initial_schema",
135 up: V1_UP,
136 },
137 VersionedMigration {
138 version: 2,
139 name: "narrow_fts_sections_update_trigger",
140 up: V2_UP,
141 },
142 VersionedMigration {
143 version: 3,
144 name: "backfill_domain_mirror_atoms",
145 up: V3_UP,
146 },
147 VersionedMigration {
148 version: 4,
149 name: "fts_consolidation",
150 up: V4_UP,
151 },
152 VersionedMigration {
153 version: 5,
154 name: "unique_comm_message_external_id",
155 up: V5_UP,
156 },
157 VersionedMigration {
158 version: 6,
159 name: "brain_retune_driver",
160 up: V6_UP,
161 },
162 VersionedMigration {
163 version: 7,
164 name: "notes_seq",
165 up: V7_UP,
166 },
167 VersionedMigration {
168 version: 8,
169 name: "notes_seq_repair",
170 up: V8_UP,
171 },
172 VersionedMigration {
173 version: 9,
174 name: "entities_name_ci_index",
175 up: V9_UP,
176 },
177 VersionedMigration {
178 version: 10,
179 name: "entities_content_ref",
180 up: V10_UP,
181 },
182];
183
184const MIGRATION_TRACKING_TABLE: &str = include_str!("../sql/schema-migrations-table.sql");
185
186pub fn read_schema_version(conn: &Connection) -> u32 {
192 conn.query_row(
193 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
194 [],
195 |row| row.get(0),
196 )
197 .unwrap_or(0)
198}
199
200pub fn inspect_schema_version(path: &std::path::Path) -> Result<u32, SqliteError> {
205 let conn = Connection::open_with_flags(
206 path,
207 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
208 )?;
209 Ok(read_schema_version(&conn))
210}
211
212pub fn run_migrations(conn: &mut Connection) -> Result<u32, SqliteError> {
213 conn.execute_batch(MIGRATION_TRACKING_TABLE)?;
214
215 let current_version: u32 = conn
216 .query_row(
217 "SELECT COALESCE(MAX(version), 0) FROM _schema_migrations",
218 [],
219 |row| row.get(0),
220 )
221 .unwrap_or(0);
222
223 let latest_version = MIGRATIONS.last().map(|m| m.version).unwrap_or(0);
229 if current_version > latest_version {
230 return Err(SqliteError::InvalidData(format!(
231 "database schema version {current_version} is ahead of the latest known migration \
232 {latest_version}. This database predates the consolidated baseline (ADR-015) or was \
233 written by a newer build. Recreate it from the current schema; in-place downgrade is \
234 not supported."
235 )));
236 }
237
238 let mut applied_version = current_version;
239
240 for migration in MIGRATIONS {
241 if migration.version <= current_version {
242 continue;
243 }
244
245 let tx = conn.transaction().map_err(|e| SqliteError::Migration {
246 version: migration.version,
247 error: e.to_string(),
248 })?;
249
250 tx.execute_batch(migration.up)
251 .map_err(|e| SqliteError::Migration {
252 version: migration.version,
253 error: e.to_string(),
254 })?;
255
256 let now = chrono::Utc::now().timestamp_micros();
257 tx.execute(
258 "INSERT INTO _schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
259 rusqlite::params![migration.version, migration.name, now],
260 )
261 .map_err(|e| SqliteError::Migration {
262 version: migration.version,
263 error: e.to_string(),
264 })?;
265
266 tx.commit().map_err(|e| SqliteError::Migration {
267 version: migration.version,
268 error: e.to_string(),
269 })?;
270
271 applied_version = migration.version;
272 }
273
274 Ok(applied_version)
275}
276
277#[derive(Debug)]
278pub struct EmbeddingModelRegistryRecord {
279 pub engine_name: String,
281 pub model_id: String,
283 pub key_version: String,
285 pub dimensions: u32,
287 pub status: String,
289 pub activated_at: Option<i64>,
291 pub superseded_at: Option<i64>,
293}
294
295pub fn query_embedding_models(
301 db: Option<&std::path::Path>,
302 engine_filter: Option<&str>,
303) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
304 let path = db.map(std::path::Path::to_path_buf).unwrap_or_else(|| {
305 std::env::var("HOME")
306 .map(std::path::PathBuf::from)
307 .unwrap_or_else(|_| std::path::PathBuf::from("."))
308 .join(".khive/khive.db")
309 });
310 if !path.exists() {
311 return Ok(Vec::new());
312 }
313 let conn = Connection::open(path)?;
314 query_embedding_models_conn(&conn, engine_filter)
315}
316
317pub(crate) fn query_embedding_models_conn(
321 conn: &Connection,
322 engine_filter: Option<&str>,
323) -> Result<Vec<EmbeddingModelRegistryRecord>, SqliteError> {
324 let exists: bool = conn.query_row(
325 "SELECT COUNT(*) > 0 FROM sqlite_master \
326 WHERE type='table' AND name='_embedding_models'",
327 [],
328 |row| row.get(0),
329 )?;
330 if !exists {
331 return Ok(Vec::new());
332 }
333
334 let sql = if engine_filter.is_some() {
335 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
336 FROM _embedding_models WHERE engine_name = ?1 \
337 ORDER BY engine_name, activated_at IS NULL, activated_at"
338 } else {
339 "SELECT engine_name, model_id, key_version, dim, status, activated_at, superseded_at \
340 FROM _embedding_models \
341 ORDER BY engine_name, activated_at IS NULL, activated_at"
342 };
343 let mut stmt = conn.prepare(sql)?;
344 let map_row = |row: &rusqlite::Row<'_>| {
345 let dim_raw: i64 = row.get(3)?;
346 let dimensions = u32::try_from(dim_raw).map_err(|_| {
347 rusqlite::Error::FromSqlConversionFailure(
348 3,
349 rusqlite::types::Type::Integer,
350 Box::new(std::io::Error::other(format!(
351 "_embedding_models.dim value {dim_raw} is outside the valid u32 range [0, {}]",
352 u32::MAX,
353 ))),
354 )
355 })?;
356 Ok(EmbeddingModelRegistryRecord {
357 engine_name: row.get(0)?,
358 model_id: row.get(1)?,
359 key_version: row.get(2)?,
360 dimensions,
361 status: row.get(4)?,
362 activated_at: row.get(5)?,
363 superseded_at: row.get(6)?,
364 })
365 };
366
367 if let Some(engine) = engine_filter {
368 stmt.query_map([engine], map_row)?
369 .collect::<Result<Vec<_>, _>>()
370 .map_err(Into::into)
371 } else {
372 stmt.query_map([], map_row)?
373 .collect::<Result<Vec<_>, _>>()
374 .map_err(Into::into)
375 }
376}
377
378#[cfg(test)]
383#[path = "migrations_tests.rs"]
384mod tests;