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
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
127/// DDL for the `ann_write_log` delta table.
128///
129/// Shared between migration V11 and the belt-and-suspenders creation in
130/// `StorageBackend::vectors_for_namespace` (same pattern as
131/// [`EMBEDDING_MODELS_DDL`]): every database that hosts `vec_*` tables must
132/// also have the write log, or vector writes would fail on databases opened
133/// without `run_migrations()`. The `.sql` file is `IF NOT EXISTS`-idempotent.
134pub const ANN_WRITE_LOG_DDL: &str = V11_UP;
135
136/// DDL for the `ann_write_log` model/kind/field-leading index (ADR-118 §"Cost
137/// bound"), shared between migration V12 and the belt-and-suspenders creation
138/// in `StorageBackend::vectors_for_namespace` for the same reason as
139/// [`ANN_WRITE_LOG_DDL`].
140pub const ANN_WRITE_LOG_MODEL_SEQ_INDEX_DDL: &str = V12_UP;
141
142/// DDL for the `_embedding_models` registry table.
143///
144/// Shared between the V1 schema and the belt-and-suspenders creation in
145/// `StorageBackend::vectors_for_namespace`. Both sites reference this constant so
146/// the schema cannot silently diverge if the registry evolves.
147pub const EMBEDDING_MODELS_DDL: &str = include_str!("../sql/embedding-models-ddl.sql");
148
149/// All versioned migrations in ascending order, applied by `run_migrations`.
150pub 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
215/// Apply all unapplied migrations in order. Idempotent; each migration runs in its own transaction.
216/// Errors on non-contiguous version array or failed migration.
217/// Read the applied schema version from an open connection **without** running
218/// migrations. Returns 0 when the `_schema_migrations` ledger is absent (an
219/// un-migrated or empty database). Never writes.
220pub 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
229/// Open `path` read-only and report its applied schema version without creating
230/// or migrating the file. The caller must ensure `path` exists — opening a
231/// missing file read-only errors rather than creating it. This is the path used
232/// by schema-inspection commands that must not mutate the database.
233pub 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    // A database whose recorded version is ahead of the latest known migration
253    // predates the consolidated V1 baseline (ADR-015) — e.g. it still carries the
254    // pre-consolidation V2..V22 ledger — or was written by a newer build. Either
255    // way the baseline schema would be silently skipped, leaving the process on a
256    // stale schema. Fail loudly instead of corrupting silently.
257    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    /// Vector engine name (e.g. `"paraphrase"`).
309    pub engine_name: String,
310    /// Model identifier (e.g. `"all-minilm-l6-v2"`).
311    pub model_id: String,
312    /// Canonical deduplication key combining engine and model.
313    pub key_version: String,
314    /// Embedding dimensionality.
315    pub dimensions: u32,
316    /// Lifecycle status (`"active"` or `"superseded"`).
317    pub status: String,
318    /// Epoch timestamp when the model was activated.
319    pub activated_at: Option<i64>,
320    /// Epoch timestamp when the model was superseded.
321    pub superseded_at: Option<i64>,
322}
323
324/// Query the `_embedding_models` registry.
325///
326/// Opens the database at `db` (defaults to `~/.khive/khive.db`) and
327/// returns all registry rows, optionally filtered by `engine_name`.
328/// Returns an empty vec if the database or table does not exist.
329pub 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
346/// Query `_embedding_models` from an existing connection (testable without a file).
347///
348/// Returns an empty vec if the table does not exist.
349pub(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// =============================================================================
408// Tests
409// =============================================================================
410
411#[cfg(test)]
412#[path = "migrations_tests.rs"]
413mod tests;