Skip to main content

khive_db/
migrations.rs

1//! Schema migration system for the SQLite storage layer.
2//!
3//! Two APIs coexist:
4//! - **Legacy per-service migrations** (`ServiceSchemaPlan` / `apply_schema_plan`):
5//!   used by pack-scoped schemas.
6//! - **Versioned migrations** (`MIGRATIONS` / `run_migrations`): the forward-only
7//!   migration pipeline for the core tables.
8
9use rusqlite::Connection;
10
11use crate::error::SqliteError;
12
13// =============================================================================
14// Legacy per-service migration API (preserved for backward compatibility)
15// =============================================================================
16
17/// A single legacy migration step within a `ServiceSchemaPlan`.
18pub struct Migration {
19    /// Unique identifier for this migration.
20    pub id: &'static str,
21    /// SQL to apply (forward direction).
22    pub up_sql: &'static str,
23    /// SQL to revert (optional).
24    pub down_sql: Option<&'static str>,
25    /// Optional predicate: returns true if migration was already applied
26    /// through a mechanism other than the migration tracker.
27    pub is_already_applied: Option<fn(&Connection) -> bool>,
28}
29
30/// A pack-scoped schema plan containing migrations for SQLite and Postgres.
31pub struct ServiceSchemaPlan {
32    /// Service name used as a key in the `_schema_versions` tracking table.
33    pub service: &'static str,
34    /// SQLite-specific migration steps, applied in order.
35    pub sqlite: &'static [Migration],
36    /// Postgres-specific migration steps (reserved for future use).
37    pub postgres: &'static [Migration],
38}
39
40const SCHEMA_VERSION_TABLE: &str = include_str!("../sql/schema-version-table.sql");
41
42/// Apply a pack-scoped schema plan, tracking each migration in `_schema_versions`.
43pub 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        // Check if custom predicate says it's already applied
48        if let Some(check) = migration.is_already_applied {
49            if check(conn) {
50                continue;
51            }
52        }
53
54        // Check if tracked as applied
55        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        // Apply
66        conn.execute_batch(migration.up_sql)?;
67
68        // Record
69        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
82// =============================================================================
83// Versioned migration system
84// =============================================================================
85
86/// A single forward-only schema migration.
87///
88/// Migrations are applied in order from the current DB version to the target
89/// version. Each migration runs in its own transaction; a failure rolls back
90/// that migration and leaves the DB at the prior version.
91pub struct VersionedMigration {
92    /// Monotonically increasing version number, starting at 1.
93    pub version: u32,
94    /// Short human-readable name for the migration (used in the audit table).
95    pub name: &'static str,
96    /// SQL to apply this migration. May contain multiple statements separated
97    /// by semicolons; `execute_batch` runs them all.
98    pub up: &'static str,
99}
100
101// V1: complete schema, loaded from sql/schema.sql.
102// Fresh-start repo (v0.2.8) — all schema in one migration, no incremental versions.
103const 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
123/// DDL for the `_embedding_models` registry table.
124///
125/// Shared between the V1 schema and the belt-and-suspenders creation in
126/// `StorageBackend::vectors_for_namespace`. Both sites reference this constant so
127/// the schema cannot silently diverge if the registry evolves.
128pub const EMBEDDING_MODELS_DDL: &str = include_str!("../sql/embedding-models-ddl.sql");
129
130/// All versioned migrations in ascending order, applied by `run_migrations`.
131pub 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
186/// Apply all unapplied migrations in order. Idempotent; each migration runs in its own transaction.
187/// Errors on non-contiguous version array or failed migration.
188/// Read the applied schema version from an open connection **without** running
189/// migrations. Returns 0 when the `_schema_migrations` ledger is absent (an
190/// un-migrated or empty database). Never writes.
191pub 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
200/// Open `path` read-only and report its applied schema version without creating
201/// or migrating the file. The caller must ensure `path` exists — opening a
202/// missing file read-only errors rather than creating it. This is the path used
203/// by schema-inspection commands that must not mutate the database.
204pub 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    // A database whose recorded version is ahead of the latest known migration
224    // predates the consolidated V1 baseline (ADR-015) — e.g. it still carries the
225    // pre-consolidation V2..V22 ledger — or was written by a newer build. Either
226    // way the baseline schema would be silently skipped, leaving the process on a
227    // stale schema. Fail loudly instead of corrupting silently.
228    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    /// Vector engine name (e.g. `"paraphrase"`).
280    pub engine_name: String,
281    /// Model identifier (e.g. `"all-minilm-l6-v2"`).
282    pub model_id: String,
283    /// Canonical deduplication key combining engine and model.
284    pub key_version: String,
285    /// Embedding dimensionality.
286    pub dimensions: u32,
287    /// Lifecycle status (`"active"` or `"superseded"`).
288    pub status: String,
289    /// Epoch timestamp when the model was activated.
290    pub activated_at: Option<i64>,
291    /// Epoch timestamp when the model was superseded.
292    pub superseded_at: Option<i64>,
293}
294
295/// Query the `_embedding_models` registry.
296///
297/// Opens the database at `db` (defaults to `~/.khive/khive.db`) and
298/// returns all registry rows, optionally filtered by `engine_name`.
299/// Returns an empty vec if the database or table does not exist.
300pub 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
317/// Query `_embedding_models` from an existing connection (testable without a file).
318///
319/// Returns an empty vec if the table does not exist.
320pub(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// =============================================================================
379// Tests
380// =============================================================================
381
382#[cfg(test)]
383#[path = "migrations_tests.rs"]
384mod tests;