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