use anyhow::Context;
pub const SCHEMA_MAIN: &str = r#"
-- Tabla de nodos: notas, entidades, etiquetas
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL,
label TEXT NOT NULL,
type TEXT NOT NULL,
embedding BLOB,
metadata TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Tabla de aristas: relaciones entre nodos
CREATE TABLE IF NOT EXISTS edges (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
type TEXT NOT NULL,
weight REAL DEFAULT 1.0,
context TEXT,
FOREIGN KEY (source_id) REFERENCES nodes(id),
FOREIGN KEY (target_id) REFERENCES nodes(id)
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_key ON nodes(key);
CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
-- Índice FTS5 para búsqueda textual (tabla independiente)
-- NOTA: Sin triggers. El build repuebla FTS5 desde Rust.
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(title, content);
-- Tabla de chunks: fragmentos de contenido con embedding
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
note_id INTEGER NOT NULL REFERENCES nodes(id),
header TEXT NOT NULL,
text TEXT NOT NULL,
slug TEXT NOT NULL,
embedding BLOB,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_chunks_note_id ON chunks(note_id);
-- Tabla de comunidades: clusters de entidades (Leiden)
CREATE TABLE IF NOT EXISTS communities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER REFERENCES communities(id),
summary TEXT,
summary_embedding BLOB,
member_ids TEXT NOT NULL DEFAULT '[]',
member_count INTEGER NOT NULL DEFAULT 0,
algorithm TEXT NOT NULL DEFAULT 'leiden',
quality_fn TEXT NOT NULL DEFAULT 'cpm',
resolution REAL NOT NULL DEFAULT 1.0,
summary_model TEXT,
embed_model TEXT,
summary_tokens INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_communities_level ON communities(level);
CREATE INDEX IF NOT EXISTS idx_communities_parent ON communities(parent_id);
"#;
#[allow(dead_code)]
pub const SCHEMA_SEED: &str = {
const SEED_INSERTS: &str = r#"
-- Seed data: bootstrap nodes
INSERT OR IGNORE INTO nodes (id, key, label, type, metadata) VALUES
(1, 'node:_root', '_root', 'root', '{"description":"Root anchor for singleton sub-graphs"}'),
(2, 'node:_graphrag_core', '_graphrag_core', 'system', '{"description":"GraphRAG kernel marker"}'),
(3, 'node:_unresolved', '_unresolved', 'system', '{"description":"Dangling reference sink"}');
-- Seed data: bootstrap edges
INSERT OR IGNORE INTO edges (source_id, target_id, type, weight, context) VALUES
(1, 2, 'contains', 1.0, 'Root contains the GraphRAG kernel');
"#;
const COMBINED: &str = {
r#"
-- Tabla de nodos: notas, entidades, etiquetas
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL,
label TEXT NOT NULL,
type TEXT NOT NULL,
embedding BLOB,
metadata TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Tabla de aristas: relaciones entre nodos
CREATE TABLE IF NOT EXISTS edges (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
type TEXT NOT NULL,
weight REAL DEFAULT 1.0,
context TEXT,
FOREIGN KEY (source_id) REFERENCES nodes(id),
FOREIGN KEY (target_id) REFERENCES nodes(id)
);
-- Índices
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_key ON nodes(key);
CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
-- Índice FTS5 para búsqueda textual (tabla independiente)
-- NOTA: Sin triggers. El build repuebla FTS5 desde Rust.
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(title, content);
-- Tabla de chunks: fragmentos de contenido con embedding
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
note_id INTEGER NOT NULL REFERENCES nodes(id),
header TEXT NOT NULL,
text TEXT NOT NULL,
slug TEXT NOT NULL,
embedding BLOB,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_chunks_note_id ON chunks(note_id);
-- Tabla de comunidades: clusters de entidades (Leiden)
CREATE TABLE IF NOT EXISTS communities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER REFERENCES communities(id),
summary TEXT,
summary_embedding BLOB,
member_ids TEXT NOT NULL DEFAULT '[]',
member_count INTEGER NOT NULL DEFAULT 0,
algorithm TEXT NOT NULL DEFAULT 'leiden',
quality_fn TEXT NOT NULL DEFAULT 'cpm',
resolution REAL NOT NULL DEFAULT 1.0,
summary_model TEXT,
embed_model TEXT,
summary_tokens INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_communities_level ON communities(level);
CREATE INDEX IF NOT EXISTS idx_communities_parent ON communities(parent_id);
-- Seed data: bootstrap nodes
INSERT OR IGNORE INTO nodes (id, key, label, type, metadata) VALUES
(1, 'node:_root', '_root', 'root', '{"description":"Root anchor for singleton sub-graphs"}'),
(2, 'node:_graphrag_core', '_graphrag_core', 'system', '{"description":"GraphRAG kernel marker"}'),
(3, 'node:_unresolved', '_unresolved', 'system', '{"description":"Dangling reference sink"}');
-- Seed data: bootstrap edges
INSERT OR IGNORE INTO edges (source_id, target_id, type, weight, context) VALUES
(1, 2, 'contains', 1.0, 'Root contains the GraphRAG kernel');
"#
};
COMBINED
};
fn column_exists(conn: &rusqlite::Connection, table: &str, column: &str) -> anyhow::Result<bool> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let name: String = row.get(1)?;
if name == column {
return Ok(true);
}
}
Ok(false)
}
pub fn init_db(conn: &rusqlite::Connection) -> anyhow::Result<()> {
conn.execute_batch("PRAGMA journal_mode=DELETE")
.context("failed to set DELETE journal mode")?;
conn.execute_batch("PRAGMA foreign_keys=ON")
.context("failed to enable foreign key enforcement")?;
let user_version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
let nodes_exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='nodes'",
[],
|r| r.get(0),
)?;
if nodes_exists > 0 && user_version < 2 && !column_exists(conn, "nodes", "key")? {
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")
.context("failed to prepare legacy table rebuild")?;
{
let tx = conn.unchecked_transaction()?;
tx.execute_batch(
"ALTER TABLE nodes RENAME TO nodes_old;
CREATE TABLE nodes (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL,
label TEXT NOT NULL,
type TEXT NOT NULL,
embedding BLOB,
metadata TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO nodes (id, key, label, type, embedding, metadata, created_at)
SELECT id,
CASE WHEN type = 'note'
THEN 'note:' || COALESCE(
json_extract(
CASE WHEN json_valid(metadata) THEN metadata ELSE NULL END,
'$.path'
),
'legacy:' || id
)
ELSE 'node:' || label
END,
label, type, embedding, metadata, created_at
FROM nodes_old;",
)
.context("failed to rebuild nodes for schema v2")?;
let old_count: i64 =
tx.query_row("SELECT COUNT(*) FROM nodes_old", [], |r| r.get(0))?;
let new_count: i64 = tx.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
anyhow::ensure!(
old_count == new_count,
"schema v2 migration integrity check failed: \
nodes_old={old_count}, nodes={new_count}"
);
tx.execute_batch("DROP TABLE nodes_old; PRAGMA user_version=2;")
.context("failed to finalize schema v2 migration")?;
tx.commit()
.context("failed to commit schema v2 migration")?;
}
conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;")
.context("failed to restore legacy table/foreign-key pragmas")?;
}
conn.execute_batch(SCHEMA_MAIN)
.context("failed to execute main schema DDL")?;
if user_version < 2 {
conn.execute_batch("PRAGMA user_version=2")
.context("failed to set schema user_version to 2")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_main_runs_without_error() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(SCHEMA_MAIN).unwrap();
}
#[test]
fn schema_seed_is_valid() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(SCHEMA_SEED).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 3, "seed should insert exactly 3 nodes");
let edge_count: i64 = conn
.query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))
.unwrap();
assert_eq!(edge_count, 1, "seed should insert exactly 1 edge");
}
#[test]
#[ignore = "needs SCHEMA_SEED which is not used in production"]
fn fts5_triggers_sync_notes_fts() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(SCHEMA_MAIN).unwrap();
conn.execute(
"INSERT INTO nodes (key, label, type, metadata) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
crate::db::keys::note_key("test-note"),
"test-note",
"note",
r#"{"content":"hello world from GraphRAG"}"#,
],
)
.unwrap();
let fts_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM notes_fts WHERE notes_fts MATCH 'hello'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(fts_count, 1, "FTS should contain the inserted content");
conn.execute(
"UPDATE nodes SET label = ?1, metadata = ?2 WHERE label = 'test-note'",
rusqlite::params!["updated-note", r#"{"content":"updated content"}"#,],
)
.unwrap();
let old_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM notes_fts WHERE notes_fts MATCH 'hello'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(old_count, 0, "old FTS entry should be deleted");
let new_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM notes_fts WHERE notes_fts MATCH 'updated'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(new_count, 1, "new FTS entry should be present");
conn.execute("DELETE FROM nodes WHERE label = 'updated-note'", [])
.unwrap();
let after_delete: i64 = conn
.query_row(
"SELECT COUNT(*) FROM notes_fts WHERE notes_fts MATCH 'updated'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(after_delete, 0, "FTS entry should be deleted with node");
}
#[test]
fn init_db_succeeds() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
let journal: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
assert!(
journal.to_uppercase() == "DELETE" || journal.to_uppercase() == "MEMORY",
"unexpected journal mode: {}",
journal
);
let fk: i64 = conn
.query_row("PRAGMA foreign_keys", [], |r| r.get(0))
.unwrap();
assert_eq!(fk, 1);
let table_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
table_count >= 5,
"expected at least 5 tables, got {table_count}"
);
}
#[test]
fn test_chunks_table_exists() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
conn.execute(
"INSERT INTO nodes (id, key, label, type, metadata) VALUES (?1, ?2, ?3, 'note', ?4)",
rusqlite::params![
1i64,
crate::db::keys::note_key("test-note"),
"test-note",
r#"{"content":"test"}"#
],
)
.unwrap();
conn.execute(
"INSERT INTO chunks (note_id, header, text, slug, embedding, metadata) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
1i64, "Introduction", "This is the chunk text.", "introduction", None::<Vec<u8>>, None::<String>, ],
)
.unwrap();
}
const LEGACY_SCHEMA: &str = r#"
CREATE TABLE nodes (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
embedding BLOB,
metadata TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
type TEXT NOT NULL,
weight REAL DEFAULT 1.0,
context TEXT,
FOREIGN KEY (source_id) REFERENCES nodes(id),
FOREIGN KEY (target_id) REFERENCES nodes(id)
);
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
note_id INTEGER NOT NULL REFERENCES nodes(id),
header TEXT NOT NULL,
text TEXT NOT NULL,
slug TEXT NOT NULL,
embedding BLOB,
metadata TEXT
);
"#;
#[test]
fn test_duplicate_labels_allowed() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
conn.execute(
"INSERT INTO nodes (key, label, type) VALUES ('note:a/dup.md', 'Duplicado', 'note')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO nodes (key, label, type) VALUES ('note:b/dup.md', 'Duplicado', 'note')",
[],
)
.unwrap();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM nodes WHERE label = 'Duplicado'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 2, "two nodes with the same label must coexist");
}
#[test]
fn test_key_unique_enforced() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
conn.execute(
"INSERT INTO nodes (key, label, type) VALUES ('note:a/dup.md', 'A', 'note')",
[],
)
.unwrap();
let err = conn.execute(
"INSERT INTO nodes (key, label, type) VALUES ('note:a/dup.md', 'B', 'note')",
[],
);
assert!(err.is_err(), "duplicate key must violate uniqueness");
}
#[test]
fn test_migrate_v1_to_v2() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(LEGACY_SCHEMA).unwrap();
conn.execute(
r#"INSERT INTO nodes (id, label, type, metadata) VALUES (1, 'Foo', 'note', '{"path":"a/foo.md"}')"#,
[],
)
.unwrap();
conn.execute(
"INSERT INTO nodes (id, label, type, metadata) VALUES (2, 'Python', 'language', '{}')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO nodes (id, label, type) VALUES (3, 'Other', 'note')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO edges (source_id, target_id, type, weight) VALUES (2, 1, 'mentioned_in', 1.0)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO chunks (note_id, header, text, slug) VALUES (1, 'H', 'body text', 'h')",
[],
)
.unwrap();
let before: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(before, 0);
init_db(&conn).unwrap();
let k1: String = conn
.query_row("SELECT key FROM nodes WHERE id = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(k1, "note:a/foo.md", "note backfilled from metadata.path");
let k2: String = conn
.query_row("SELECT key FROM nodes WHERE id = 2", [], |r| r.get(0))
.unwrap();
assert_eq!(k2, "node:Python", "non-note backfilled from label");
let k3: String = conn
.query_row("SELECT key FROM nodes WHERE id = 3", [], |r| r.get(0))
.unwrap();
assert_eq!(k3, "note:legacy:3", "note without path uses legacy key");
let label1: String = conn
.query_row("SELECT label FROM nodes WHERE id = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(label1, "Foo");
let edge_ok: i64 = conn
.query_row(
"SELECT COUNT(*) FROM edges e JOIN nodes s ON e.source_id = s.id JOIN nodes t ON e.target_id = t.id",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(edge_ok, 1, "edge FKs must survive migration");
let chunk_ok: i64 = conn
.query_row(
"SELECT COUNT(*) FROM chunks c JOIN nodes n ON c.note_id = n.id",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(chunk_ok, 1, "chunk FKs must survive migration");
let idx: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_nodes_key'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(idx, 1, "idx_nodes_key must exist");
let uv: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(uv, 2, "user_version must be 2 after migration");
}
#[test]
fn test_migrate_idempotent() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init_db(&conn).unwrap();
conn.execute(
"INSERT INTO nodes (key, label, type) VALUES ('note:a/foo.md', 'Foo', 'note')",
[],
)
.unwrap();
init_db(&conn).unwrap();
let key: String = conn
.query_row("SELECT key FROM nodes WHERE label = 'Foo'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(key, "note:a/foo.md", "key must be unchanged");
let uv: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(uv, 2);
}
}