hephaestus/crates/heph-core/src/sqlite/migrations.rs
Erich Blume 5d8ec45c55
Some checks failed
Build / validate (pull_request) Failing after 3s
heph-core: full-text search (FTS5)
Slice query-surface, part 2 (tech-spec §6). Migration v2 adds an FTS5
external-content table over nodes(title, body), kept in sync by
insert/update/delete triggers (with a backfill for existing rows).

- Store::search(query): owner-scoped, tombstones excluded, best-match
  first (FTS5 MATCH + rank). Exposed over RPC; `heph search` and
  `heph journal` CLI commands added.

3 search integration tests (title/body match, edits reflected via trigger,
tombstone exclusion, all insert paths indexed). 79 tests green. This
completes the local feature surface; the remaining slices are the
distributed/auth/nvim layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:43:05 -07:00

134 lines
4.2 KiB
Rust

//! Schema migrations, applied in order against SQLite's `user_version`.
//!
//! Each migration is `(version, sql)`. On open we read `PRAGMA user_version`
//! and apply every migration whose version is greater, bumping the pragma as we
//! go. Migrations are additive across build slices (tech-spec §4.5 is a
//! starting point; later slices add tables such as `nodes_fts`).
use crate::error::Result;
use rusqlite::Connection;
/// The ordered list of migrations. Never reorder or mutate a shipped entry —
/// only append.
const MIGRATIONS: &[(i64, &str)] = &[(1, MIGRATION_0001), (2, MIGRATION_0002)];
/// v1 — the base node graph, identity, and sync scaffolding (tech-spec §4.5).
const MIGRATION_0001: &str = r#"
CREATE TABLE users (
id TEXT PRIMARY KEY,
oidc_sub TEXT UNIQUE,
name TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE nodes (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT,
body_crdt BLOB,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
hlc TEXT NOT NULL,
tombstoned INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_nodes_owner_kind ON nodes(owner_id, kind);
CREATE TABLE tasks (
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
attention TEXT,
do_date INTEGER,
late_on INTEGER,
state TEXT NOT NULL,
recurrence TEXT
);
CREATE TABLE links (
id TEXT PRIMARY KEY,
src_id TEXT NOT NULL REFERENCES nodes(id),
dst_id TEXT NOT NULL REFERENCES nodes(id),
type TEXT NOT NULL,
created_at INTEGER NOT NULL,
tombstoned INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_links_src ON links(src_id, type);
CREATE INDEX idx_links_dst ON links(dst_id, type);
CREATE TABLE aliases (
node_id TEXT REFERENCES nodes(id),
alias TEXT
);
CREATE INDEX idx_aliases_alias ON aliases(alias);
CREATE TABLE oplog (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
hlc TEXT NOT NULL,
origin TEXT NOT NULL,
op_type TEXT NOT NULL,
target_id TEXT NOT NULL,
payload TEXT NOT NULL,
applied INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_state (
peer TEXT PRIMARY KEY,
last_pushed_hlc TEXT,
last_pulled_hlc TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE conflicts (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
node_id TEXT NOT NULL REFERENCES nodes(id),
field TEXT NOT NULL,
local_val TEXT,
remote_val TEXT,
local_hlc TEXT,
remote_hlc TEXT,
status TEXT NOT NULL,
created_at INTEGER NOT NULL
);
"#;
/// v2 — full-text search over title + body via FTS5 (external content over
/// `nodes`), kept in sync by triggers (tech-spec §4.5).
const MIGRATION_0002: &str = r#"
CREATE VIRTUAL TABLE nodes_fts USING fts5(
title, body, content='nodes', content_rowid='rowid'
);
-- Index any rows that already exist.
INSERT INTO nodes_fts(rowid, title, body) SELECT rowid, title, body FROM nodes;
CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body);
END;
CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body);
END;
CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body);
INSERT INTO nodes_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body);
END;
"#;
/// Apply all pending migrations to `conn`.
pub fn migrate(conn: &Connection) -> Result<()> {
let current: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
for &(version, sql) in MIGRATIONS {
if version > current {
conn.execute_batch(sql)?;
// user_version doesn't accept bound params; the value is a trusted const.
conn.execute_batch(&format!("PRAGMA user_version = {version}"))?;
}
}
Ok(())
}
/// The schema version this build migrates up to (the latest migration number).
pub fn latest_version() -> i64 {
MIGRATIONS.last().map(|&(v, _)| v).unwrap_or(0)
}