grit-core 0.2.3

Embedded, bi-temporal property graph for agent memory: one SQLite file, in-process, deterministic
Documentation
//! Forward-only schema migrations via `PRAGMA user_version`
//! (Design Invariant 8).

use rusqlite::Connection;

use crate::error::{Error, Result};

/// The schema version this build reads and writes.
pub const SCHEMA_VERSION: i64 = 4;

/// Creates the LATEST schema from scratch (schema.sql tracks head).
const SCHEMA_LATEST: &str = include_str!("schema.sql");

/// v1 → v2 (grit 0.2.0): per-field LWW records backing `UpdateNode`.
/// Frozen — never edit; schema.sql carries the same DDL for fresh DBs.
const MIGRATE_1_TO_2: &str = "
CREATE TABLE node_updates (
    node_id TEXT NOT NULL,
    field   TEXT NOT NULL,
    value   TEXT NOT NULL,
    hlc     TEXT NOT NULL,
    PRIMARY KEY (node_id, field)
) STRICT, WITHOUT ROWID;
";

/// v2 → v3 (grit 0.2.2): `episodes.kind` — a free-form source-kind tag
/// (`"message"` / `"text"` / `"json"`), opaque to grit, empty for
/// pre-existing rows. Frozen — never edit.
const MIGRATE_2_TO_3: &str = "
ALTER TABLE episodes ADD COLUMN kind TEXT NOT NULL DEFAULT '';
";

/// v3 → v4 (grit 0.2.3): trigram FTS mirrors for CJK substring recall —
/// tables + triggers identical to schema.sql, then a rebuild populates
/// them from existing rows (external-content FTS reads the base tables).
/// Frozen — never edit.
const MIGRATE_3_TO_4: &str = "
CREATE VIRTUAL TABLE nodes_fts_tri USING fts5(
    name, summary,
    content='nodes', content_rowid='rowid',
    tokenize='trigram'
);
CREATE TRIGGER nodes_fts_tri_ai AFTER INSERT ON nodes BEGIN
    INSERT INTO nodes_fts_tri(rowid, name, summary)
    VALUES (new.rowid, new.name, new.summary);
END;
CREATE TRIGGER nodes_fts_tri_ad AFTER DELETE ON nodes BEGIN
    INSERT INTO nodes_fts_tri(nodes_fts_tri, rowid, name, summary)
    VALUES ('delete', old.rowid, old.name, old.summary);
END;
CREATE TRIGGER nodes_fts_tri_au AFTER UPDATE OF name, summary ON nodes BEGIN
    INSERT INTO nodes_fts_tri(nodes_fts_tri, rowid, name, summary)
    VALUES ('delete', old.rowid, old.name, old.summary);
    INSERT INTO nodes_fts_tri(rowid, name, summary)
    VALUES (new.rowid, new.name, new.summary);
END;
CREATE VIRTUAL TABLE edges_fts_tri USING fts5(
    fact,
    content='edges', content_rowid='rowid',
    tokenize='trigram'
);
CREATE TRIGGER edges_fts_tri_ai AFTER INSERT ON edges BEGIN
    INSERT INTO edges_fts_tri(rowid, fact) VALUES (new.rowid, new.fact);
END;
CREATE TRIGGER edges_fts_tri_ad AFTER DELETE ON edges BEGIN
    INSERT INTO edges_fts_tri(edges_fts_tri, rowid, fact)
    VALUES ('delete', old.rowid, old.fact);
END;
CREATE TRIGGER edges_fts_tri_au AFTER UPDATE OF fact ON edges BEGIN
    INSERT INTO edges_fts_tri(edges_fts_tri, rowid, fact)
    VALUES ('delete', old.rowid, old.fact);
    INSERT INTO edges_fts_tri(rowid, fact) VALUES (new.rowid, new.fact);
END;
CREATE VIRTUAL TABLE episodes_fts_tri USING fts5(
    content,
    content='episodes', content_rowid='rowid',
    tokenize='trigram'
);
CREATE TRIGGER episodes_fts_tri_ai AFTER INSERT ON episodes BEGIN
    INSERT INTO episodes_fts_tri(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER episodes_fts_tri_ad AFTER DELETE ON episodes BEGIN
    INSERT INTO episodes_fts_tri(episodes_fts_tri, rowid, content)
    VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER episodes_fts_tri_au AFTER UPDATE OF content ON episodes BEGIN
    INSERT INTO episodes_fts_tri(episodes_fts_tri, rowid, content)
    VALUES ('delete', old.rowid, old.content);
    INSERT INTO episodes_fts_tri(rowid, content) VALUES (new.rowid, new.content);
END;
INSERT INTO nodes_fts_tri(nodes_fts_tri) VALUES ('rebuild');
INSERT INTO edges_fts_tri(edges_fts_tri) VALUES ('rebuild');
INSERT INTO episodes_fts_tri(episodes_fts_tri) VALUES ('rebuild');
";

/// Bring `conn`'s schema up to [`SCHEMA_VERSION`]. A brand-new database gets
/// the full latest schema; older versions are migrated step by step; a newer
/// version is refused (migrations are forward-only, never down).
pub(crate) fn migrate(conn: &mut Connection) -> Result<()> {
    loop {
        let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
        match version {
            SCHEMA_VERSION => return Ok(()),
            v if v > SCHEMA_VERSION => {
                return Err(Error::SchemaTooNew {
                    found: v,
                    supported: SCHEMA_VERSION,
                });
            }
            0 => apply_step(conn, SCHEMA_LATEST, SCHEMA_VERSION)?,
            1 => apply_step(conn, MIGRATE_1_TO_2, 2)?,
            2 => apply_step(conn, MIGRATE_2_TO_3, 3)?,
            3 => apply_step(conn, MIGRATE_3_TO_4, 4)?,
            v => unreachable!("no migration path from version {v}"),
        }
    }
}

fn apply_step(conn: &mut Connection, sql: &str, target: i64) -> Result<()> {
    let tx = conn.transaction()?;
    tx.execute_batch(sql)?;
    // PRAGMA can't take bound parameters; target is a compile-time constant.
    tx.pragma_update(None, "user_version", target)?;
    tx.commit()?;
    Ok(())
}