use crate::{Error, Result};
use rusqlite::Connection;
pub(crate) const SCHEMA_VERSION: i64 = 12;
pub(crate) const APPLICATION_ID: i64 = 0x5041494d;
const MIGRATION_3_TO_4: &str = "
CREATE TABLE predicate_rules (
predicate_id INTEGER PRIMARY KEY REFERENCES strings(id) ON DELETE CASCADE,
inverse_predicate_id INTEGER REFERENCES strings(id) ON DELETE RESTRICT,
is_symmetric INTEGER NOT NULL DEFAULT 0,
CHECK (is_symmetric IN (0, 1)),
CHECK (is_symmetric = 0 OR inverse_predicate_id IS NULL)
);
INSERT OR IGNORE INTO strings(text) VALUES ('sys:same_as');
INSERT OR IGNORE INTO predicate_rules(predicate_id, is_symmetric) SELECT id, 1 FROM strings WHERE text='sys:same_as';
";
const MIGRATION_4_TO_5: &str = "
ALTER TABLE records DROP COLUMN search_text;
ALTER TABLE records DROP COLUMN embedding_text;
";
const MIGRATION_8_TO_9: &str = "
CREATE TABLE namespace_roots (
namespace_id INTEGER PRIMARY KEY REFERENCES strings(id) ON DELETE CASCADE,
root TEXT NOT NULL
);
";
const MIGRATION_9_TO_10: &str = "
CREATE TABLE predicate_equivalents (
namespace_id INTEGER NOT NULL REFERENCES strings(id) ON DELETE CASCADE,
predicate_id INTEGER NOT NULL REFERENCES strings(id) ON DELETE CASCADE,
canonical_id INTEGER NOT NULL REFERENCES strings(id) ON DELETE CASCADE,
PRIMARY KEY (namespace_id, predicate_id)
);
CREATE INDEX predicate_equivalents_group ON predicate_equivalents(namespace_id, canonical_id);
";
fn strip_chunk_payload_keys(tx: &rusqlite::Transaction<'_>, keys: &[&str]) -> Result<()> {
let mut rows: Vec<(i64, String)> = Vec::new();
{
let mut stmt = tx.prepare("SELECT id,payload_json FROM records WHERE kind=?1")?;
for row in stmt.query_map([crate::types::RecordKind::Chunk.code()], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
rows.push(row?);
}
}
for (id, raw) in rows {
let mut value: serde_json::Value = serde_json::from_str(&raw)?;
if let Some(object) = value.as_object_mut() {
for key in keys { object.remove(*key); }
}
tx.execute("UPDATE records SET payload_json=?2 WHERE id=?1", rusqlite::params![id, serde_json::to_string(&value)?])?;
}
Ok(())
}
fn migrate_note_payloads(tx: &rusqlite::Transaction<'_>) -> Result<()> {
let mut rows: Vec<(i64, String)> = Vec::new();
{
let mut stmt = tx.prepare("SELECT id,payload_json FROM records WHERE kind=?1")?;
for row in stmt.query_map([crate::types::RecordKind::Note.code()], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? {
rows.push(row?);
}
}
for (id, raw) in rows {
let mut value: serde_json::Value = serde_json::from_str(&raw)?;
if let Some(object) = value.as_object_mut() {
for key in ["content", "title", "chunk_count", "source_revision", "source"] { object.remove(key); }
}
tx.execute("UPDATE records SET payload_json=?2 WHERE id=?1", rusqlite::params![id, serde_json::to_string(&value)?])?;
}
Ok(())
}
fn migrate_note_paths(tx: &rusqlite::Transaction<'_>) -> Result<()> {
tx.execute_batch("
CREATE TABLE notes_new (
record_id INTEGER PRIMARY KEY REFERENCES records(id) ON DELETE CASCADE,
namespace_id INTEGER NOT NULL REFERENCES strings(id) ON DELETE RESTRICT,
scope_id INTEGER NOT NULL REFERENCES strings(id) ON DELETE RESTRICT,
path TEXT NOT NULL,
UNIQUE(namespace_id, scope_id, path)
);
INSERT INTO notes_new(record_id,namespace_id,scope_id,path)
SELECT n.record_id,n.namespace_id,n.scope_id,s.text
FROM notes n JOIN strings s ON s.id=n.source_id;
DROP TABLE notes;
ALTER TABLE notes_new RENAME TO notes;
")?;
Ok(())
}
fn migrate_note_names(tx: &rusqlite::Transaction<'_>) -> Result<()> {
tx.execute_batch("ALTER TABLE notes ADD COLUMN name TEXT NOT NULL DEFAULT ''")?;
let rows: Vec<(i64, String)> = {
let mut stmt = tx.prepare("SELECT record_id,path FROM notes")?;
let collected = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?
.collect::<std::result::Result<Vec<_>, _>>()?;
collected
};
let mut update = tx.prepare("UPDATE notes SET name=?2 WHERE record_id=?1")?;
for (id, path) in rows {
let stem = std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
update.execute(rusqlite::params![id, stem])?;
}
Ok(())
}
pub(crate) fn initialize(conn: &mut Connection) -> Result<()> {
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version != 0 && !(2..=SCHEMA_VERSION).contains(&version) {
return Err(Error::SchemaVersion { found: version, supported: SCHEMA_VERSION });
}
let application: i64 = conn.pragma_query_value(None, "application_id", |r| r.get(0))?;
let tables: i64 = conn.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", [], |r| r.get(0))?;
if (application != 0 && application != APPLICATION_ID) || (application == 0 && tables > 0) {
return Err(Error::Conflict("this is not a p-memory database; use the legacy importer".into()));
}
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;")?;
if version == 0 {
let tx = conn.transaction()?;
tx.execute_batch(include_str!("schema.sql"))?;
tx.pragma_update(None, "application_id", APPLICATION_ID)?;
tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
tx.commit()?;
} else {
migrate(conn, version)?;
}
Ok(())
}
fn migrate(conn: &mut Connection, version: i64) -> Result<()> {
conn.pragma_update(None, "foreign_keys", false)?;
let outcome = migrate_steps(conn, version);
conn.pragma_update(None, "foreign_keys", true)?;
outcome
}
fn migrate_steps(conn: &mut Connection, mut version: i64) -> Result<()> {
while version < SCHEMA_VERSION {
let tx = conn.transaction()?;
match version {
2 => { tx.execute_batch("ALTER TABLE embedding_spaces ADD COLUMN encoding TEXT NOT NULL DEFAULT 'f32'")?; }
3 => { tx.execute_batch(MIGRATION_3_TO_4)?; }
4 => { tx.execute_batch(MIGRATION_4_TO_5)?; strip_chunk_payload_keys(&tx, &["content"])?; }
5 => { migrate_note_payloads(&tx)?; }
6 => { migrate_note_paths(&tx)?; }
7 => { strip_chunk_payload_keys(&tx, &["char_start", "char_end"])?; }
8 => { tx.execute_batch(MIGRATION_8_TO_9)?; }
9 => { tx.execute_batch(MIGRATION_9_TO_10)?; }
10 => { migrate_note_names(&tx)?; }
11 => { tx.execute_batch("CREATE INDEX embeddings_by_record ON embeddings(record_id)")?; }
other => return Err(Error::SchemaVersion { found: other, supported: SCHEMA_VERSION }),
}
version += 1;
tx.pragma_update(None, "user_version", version)?;
tx.commit()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn legacy_db(directory: &std::path::Path, version: i64, rollback: &str) {
let conn = Connection::open(directory.join("store.sqlite3")).unwrap();
conn.execute_batch(include_str!("schema.sql")).unwrap();
conn.execute_batch(rollback).unwrap();
if version < 5 {
conn.execute_batch("ALTER TABLE records ADD COLUMN search_text TEXT NOT NULL DEFAULT ''; \
ALTER TABLE records ADD COLUMN embedding_text TEXT NOT NULL DEFAULT '';").unwrap();
}
if version < 7 {
conn.execute_batch("CREATE TABLE notes_legacy (
record_id INTEGER PRIMARY KEY REFERENCES records(id) ON DELETE CASCADE,
namespace_id INTEGER NOT NULL REFERENCES strings(id) ON DELETE RESTRICT,
scope_id INTEGER NOT NULL REFERENCES strings(id) ON DELETE RESTRICT,
source_id INTEGER NOT NULL REFERENCES strings(id) ON DELETE RESTRICT,
UNIQUE(namespace_id, scope_id, source_id)
);
DROP TABLE notes;
ALTER TABLE notes_legacy RENAME TO notes;").unwrap();
}
if version < 9 {
conn.execute_batch("DROP TABLE namespace_roots;").unwrap();
}
if version < 10 {
conn.execute_batch("DROP TABLE predicate_equivalents;").unwrap();
}
if (7..11).contains(&version) {
conn.execute_batch("ALTER TABLE notes DROP COLUMN name;").unwrap();
}
if version < 12 {
conn.execute_batch("DROP INDEX embeddings_by_record;").unwrap();
}
conn.pragma_update(None, "application_id", APPLICATION_ID).unwrap();
conn.pragma_update(None, "user_version", version).unwrap();
}
#[test]
fn rolls_v3_forward_to_current() {
let dir = tempfile::tempdir().unwrap();
legacy_db(dir.path(), 3, "DROP TABLE predicate_rules; DELETE FROM strings WHERE text='sys:same_as';");
let kb = crate::KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().schema_version, SCHEMA_VERSION);
kb.graph().set_predicate_rule("父亲", Some("子女"), false).unwrap();
}
#[test]
fn rolls_v2_forward_adding_encoding_and_rules() {
let dir = tempfile::tempdir().unwrap();
legacy_db(dir.path(), 2, "DROP TABLE predicate_rules; DELETE FROM strings WHERE text='sys:same_as'; ALTER TABLE embedding_spaces DROP COLUMN encoding;");
let kb = crate::KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().schema_version, SCHEMA_VERSION);
kb.graph().set_predicate_rule("同事", None, true).unwrap();
}
#[test]
fn rolls_v8_forward_adding_domain_roots() {
let dir = tempfile::tempdir().unwrap();
legacy_db(dir.path(), 8, "");
let kb = crate::KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().schema_version, SCHEMA_VERSION);
assert!(kb.notes().root("default").unwrap().is_none(), "旧库没有登记过根目录");
kb.notes().set_root("default", &dir.path().to_string_lossy()).unwrap();
assert!(kb.notes().root("default").unwrap().is_some());
}
#[test]
fn rolls_v9_forward_adding_predicate_equivalents() {
let dir = tempfile::tempdir().unwrap();
legacy_db(dir.path(), 9, "");
let kb = crate::KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().schema_version, SCHEMA_VERSION);
assert!(kb.graph().predicate_equivalents("demo").unwrap().is_empty(), "旧库没登记过等价词");
kb.graph().set_predicate_equivalents("demo", &[vec!["alpha".into(), "beta".into()]]).unwrap();
assert_eq!(kb.graph().predicate_equivalents("demo").unwrap().len(), 1, "迁移后新表可用");
}
#[test]
fn rolls_v11_forward_indexing_the_vector_table() {
let dir = tempfile::tempdir().unwrap();
legacy_db(dir.path(), 11, "");
let kb = crate::KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().schema_version, SCHEMA_VERSION);
let conn = Connection::open(dir.path().join("store.sqlite3")).unwrap();
conn.pragma_update(None, "foreign_keys", true).unwrap();
let plans: Vec<String> = conn.prepare("EXPLAIN QUERY PLAN DELETE FROM records WHERE id=1").unwrap()
.query_map([], |row| row.get::<_, String>(3)).unwrap().map(|row| row.unwrap()).collect();
let embedding_plan = plans.iter().find(|plan| plan.contains("embeddings")).expect("删父行会级联到向量表");
assert!(embedding_plan.starts_with("SEARCH"), "级联删除必须走索引而不是全表扫描:{embedding_plan}");
}
#[test]
fn rolls_v4_forward_dropping_derived_columns() {
let dir = tempfile::tempdir().unwrap();
legacy_db(dir.path(), 4, "");
let note_file = dir.path().join("a.md");
std::fs::write(¬e_file, "甲乙丙").unwrap();
let source_path = note_file.to_string_lossy().replace('\\', "/");
{
let conn = Connection::open(dir.path().join("store.sqlite3")).unwrap();
conn.execute_batch(&format!(r#"
INSERT INTO strings(id,text) VALUES (10,'ns'),(11,'sc'),(12,'{source_path}');
INSERT INTO records(id,namespace_id,kind,scope_id,created_at_us,updated_at_us,revision,metadata_json,evidence_json,search_text,embedding_text,fingerprint,payload_json)
VALUES (1,10,4,11,1,1,1,'{{}}','[]','T','T','nf','{{"source":"{source_path}","title":"T","content":"甲乙丙","source_revision":"x","chunk_chars":220,"chunk_count":1}}');
INSERT INTO notes(record_id,namespace_id,scope_id,source_id) VALUES (1,10,11,12);
INSERT INTO records(id,namespace_id,kind,scope_id,created_at_us,updated_at_us,revision,metadata_json,evidence_json,search_text,embedding_text,fingerprint,payload_json)
VALUES (2,10,5,11,1,1,1,'{{}}','[]','T','T','cfp','{{"note_id":1,"ordinal":0,"offset":1,"limit":1,"content":"甲乙丙"}}');
INSERT INTO chunks(record_id,note_id,ordinal,"offset","limit",fingerprint) VALUES (2,1,0,1,1,'cfp');
"#)).unwrap();
}
let kb = crate::KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().schema_version, SCHEMA_VERSION);
let conn = Connection::open(dir.path().join("store.sqlite3")).unwrap();
let leftover: i64 = conn.query_row("SELECT COUNT(*) FROM pragma_table_info('records') WHERE name IN ('search_text','embedding_text')", [], |r| r.get(0)).unwrap();
assert_eq!(leftover, 0, "派生文本列应在迁移中删除");
let payload: String = conn.query_row("SELECT payload_json FROM records WHERE id=2", [], |r| r.get(0)).unwrap();
let value: serde_json::Value = serde_json::from_str(&payload).unwrap();
assert!(value.get("content").is_none(), "切片 payload 不再存正文");
assert!(value.get("char_start").is_none() && value.get("char_end").is_none(), "字符区间随 v8 一并摘除");
assert_eq!(value.get("offset").and_then(|v| v.as_u64()), Some(1), "行区间保留");
assert_eq!(value.get("limit").and_then(|v| v.as_u64()), Some(1));
let note_payload: String = conn.query_row("SELECT payload_json FROM records WHERE id=1", [], |r| r.get(0)).unwrap();
let note: serde_json::Value = serde_json::from_str(¬e_payload).unwrap();
assert!(note.get("content").is_none(), "笔记 payload 不再存正文");
assert!(note.get("title").is_none(), "笔记标题由路径派生,不落库");
assert!(note.get("source").is_none(), "路径只落在 notes 表,payload 不重复");
assert_eq!(note.get("chunk_chars").and_then(|v| v.as_u64()), Some(220), "只留切片粒度");
let migrated_path: String = conn.query_row("SELECT path FROM notes WHERE record_id=1", [], |r| r.get(0)).unwrap();
assert_eq!(migrated_path, source_path, "笔记路径应从标签字典原样迁到 path 列");
}
#[test]
fn rolls_v10_forward_backfilling_note_names() {
let dir = tempfile::tempdir().unwrap();
legacy_db(dir.path(), 10, "");
let note_path = dir.path().join("notes").join("characters").join("overview.md");
std::fs::create_dir_all(note_path.parent().unwrap()).unwrap();
std::fs::write(¬e_path, "甲乙丙").unwrap();
{
let conn = Connection::open(dir.path().join("store.sqlite3")).unwrap();
conn.execute_batch(&format!(r#"
INSERT INTO strings(id,text) VALUES (10,'ns'),(11,'sc'),(12,'notes/characters/overview.md');
INSERT INTO records(id,namespace_id,kind,scope_id,created_at_us,updated_at_us,revision,metadata_json,evidence_json,fingerprint,payload_json)
VALUES (1,10,4,11,1,1,1,'{{}}','[]','nf','{{"chunk_chars":220}}');
INSERT INTO notes(record_id,namespace_id,scope_id,path) VALUES (1,10,11,'notes/characters/overview.md');
"#)).unwrap();
}
let kb = crate::KnowledgeBase::open(dir.path()).unwrap();
assert_eq!(kb.health().unwrap().schema_version, SCHEMA_VERSION);
let conn = Connection::open(dir.path().join("store.sqlite3")).unwrap();
let name: String = conn.query_row("SELECT name FROM notes WHERE record_id=1", [], |r| r.get(0)).unwrap();
assert_eq!(name, "overview", "迁移就地按 path 回填文件名(去扩展名)");
}
}