generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Failing after 3s
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>
134 lines
4.2 KiB
Rust
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)
|
|
}
|