Skip to main content

sqlite_graphrag/commands/
init.rs

1//! Handler for the `init` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::pragmas::{apply_init_pragmas, ensure_wal_mode};
7use crate::storage::connection::open_rw;
8use serde::Serialize;
9
10/// Embedding model choices exposed through `--model`.
11///
12/// Legacy flag kept for CLI compatibility only: since v1.0.76 the build is
13/// LLM-only and no local model is downloaded. The value is accepted and
14/// ignored; `schema_meta.model` records the CLI version (G46).
15#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
16pub enum EmbeddingModelChoice {
17    /// Multilingual E 5 small variant.
18    #[value(name = "multilingual-e5-small")]
19    MultilingualE5Small,
20}
21
22#[derive(clap::Args)]
23#[command(after_long_help = "EXAMPLES:\n  \
24    # Initialize a new database in the current directory\n  \
25    sqlite-graphrag init\n\n  \
26    # Initialize with a specific namespace\n  \
27    sqlite-graphrag init --namespace my-project\n\n  \
28    # Initialize at a custom database path\n  \
29    sqlite-graphrag init --db /path/to/graphrag.sqlite")]
30/// Init args.
31pub struct InitArgs {
32    /// Path to graphrag.sqlite.
33    ///
34    /// Resolution precedence (highest to lowest): `--db` flag > XDG `db.path`
35    /// > `graphrag.sqlite` under the XDG data directory. The current working
36    /// > directory is NOT part of the cascade on a host with a home directory.
37    #[arg(long)]
38    pub db: Option<String>,
39    /// Legacy embedding model identifier (accepted and ignored since the
40    /// v1.0.76 LLM-only build; kept for CLI compatibility). Safe to omit.
41    #[arg(long, value_enum)]
42    pub model: Option<EmbeddingModelChoice>,
43    /// Force re-initialization, overwriting any existing schema metadata.
44    /// Use only when the schema is corrupted; loses configuration but preserves data.
45    #[arg(long)]
46    pub force: bool,
47    /// Initial namespace to resolve. Aligned with bilingual docs that mention `init --namespace`.
48    /// When provided, overrides XDG `namespace.default`; otherwise falls back to `global`.
49    #[arg(long)]
50    pub namespace: Option<String>,
51    /// Emit machine-readable JSON on stdout.
52    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
53    pub json: bool,
54}
55
56#[derive(Serialize)]
57struct InitResponse {
58    db_path: String,
59    /// Latest applied migration number from `refinery_schema_history`.
60    /// Emitted as a JSON number for cross-command consistency with `health` and `stats` (since v1.0.35).
61    schema_version: u32,
62    model: String,
63    dim: usize,
64    /// Active namespace resolved during initialisation, aligned with the bilingual docs.
65    namespace: String,
66    status: String,
67    /// Total execution time in milliseconds from handler start to serialisation.
68    elapsed_ms: u64,
69}
70
71/// Run.
72pub fn run(
73    args: InitArgs,
74    llm_backend: crate::cli::LlmBackendChoice,
75    embedding_backend: crate::cli::EmbeddingBackendChoice,
76) -> Result<(), AppError> {
77    let start = std::time::Instant::now();
78    let paths = AppPaths::resolve(args.db.as_deref())?;
79    paths.ensure_dirs()?;
80
81    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
82
83    let mut conn = open_rw(&paths.db)?;
84
85    apply_init_pragmas(&conn)?;
86
87    crate::migrations::runner()
88        .run(&mut conn)
89        .map_err(|e| AppError::Internal(anyhow::anyhow!("migration failed: {e}")))?;
90
91    conn.execute_batch(&format!(
92        "PRAGMA user_version = {};",
93        crate::constants::SCHEMA_USER_VERSION
94    ))?;
95
96    // Defensive re-assertion: refinery may revert journal_mode during migrations.
97    ensure_wal_mode(&conn)?;
98
99    let schema_version = latest_schema_version(&conn)?;
100
101    conn.execute(
102        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
103        rusqlite::params![schema_version],
104    )?;
105    conn.execute(
106        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('model', ?1)",
107        rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
108    )?;
109    // G43: pre-v1.0.79 this hardcoded '384' as a literal, bypassing the
110    // active default (DEFAULT_EMBEDDING_DIM = 1024).
111    // INSERT OR IGNORE preserves the recorded dim on re-init of an existing
112    // database; the active dim (env > database > default) fills new ones.
113    conn.execute(
114        "INSERT OR IGNORE INTO schema_meta (key, value) VALUES ('dim', ?1)",
115        rusqlite::params![crate::constants::embedding_dim().to_string()],
116    )?;
117    conn.execute(
118        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT))",
119        [],
120    )?;
121    conn.execute(
122        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('sqlite-graphrag_version', ?1)",
123        rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
124    )?;
125    // Persist the resolved namespace so downstream tools can inspect it without re-resolving.
126    conn.execute(
127        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('namespace_initial', ?1)",
128        rusqlite::params![namespace],
129    )?;
130
131    output::emit_progress_i18n(
132        "Validating embedding backend...",
133        "Validando backend de embedding...",
134    );
135
136    // GAP-INIT-EMBEDDING-001 FIX (v1.0.89): init must succeed without LLM.
137    // Schema, tables and FTS5 are created above; the smoke test only validates
138    // that the embedding subprocess is reachable. When it is not (OAuth expired,
139    // CLI missing), init still succeeds with dim from the database or default.
140    // ADR-0011: Validation errors (OAuth-only enforcement) are FATAL — propagate.
141    // v1.0.89 (GAP-EMBED-PROPAGATION): honour --llm-backend via embed_passage_with_choice.
142    let (dim, status) = match crate::embedder::embed_passage_with_embedding_choice(
143        &paths.models,
144        "smoke test",
145        embedding_backend,
146        llm_backend,
147    ) {
148        Ok((v, _backend)) => (v.len(), "ok"),
149        Err(crate::errors::AppError::Validation(msg)) => {
150            return Err(crate::errors::AppError::Validation(msg))
151        }
152        Err(e) => {
153            tracing::warn!(target: "init", error = %e, "embedding smoke test failed; init continues without LLM validation");
154            (crate::constants::embedding_dim(), "ok_no_embedding")
155        }
156    };
157
158    output::emit_json(&InitResponse {
159        db_path: paths.db.display().to_string(),
160        schema_version,
161        model: crate::constants::SQLITE_GRAPHRAG_VERSION.to_string(),
162        dim,
163        namespace,
164        status: status.to_string(),
165        elapsed_ms: start.elapsed().as_millis() as u64,
166    })?;
167
168    Ok(())
169}
170
171fn latest_schema_version(conn: &rusqlite::Connection) -> Result<u32, AppError> {
172    match conn.query_row(
173        "SELECT version FROM refinery_schema_history ORDER BY version DESC LIMIT 1",
174        [],
175        |row| row.get::<_, i64>(0),
176    ) {
177        Ok(version) => Ok(version.max(0) as u32),
178        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0),
179        Err(err) => Err(AppError::Database(err)),
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn init_response_serializes_all_fields() {
189        let resp = InitResponse {
190            db_path: "/tmp/test.sqlite".to_string(),
191            schema_version: 6,
192            model: crate::constants::SQLITE_GRAPHRAG_VERSION.to_string(),
193            dim: crate::constants::DEFAULT_EMBEDDING_DIM,
194            namespace: "global".to_string(),
195            status: "ok".to_string(),
196            elapsed_ms: 100,
197        };
198        let json = serde_json::to_value(&resp).expect("serialization failed");
199        assert_eq!(json["db_path"], "/tmp/test.sqlite");
200        assert_eq!(json["schema_version"], 6);
201        assert_eq!(json["model"], crate::constants::SQLITE_GRAPHRAG_VERSION);
202        assert_eq!(json["dim"], crate::constants::DEFAULT_EMBEDDING_DIM);
203        assert_eq!(json["namespace"], "global");
204        assert_eq!(json["status"], "ok");
205        assert!(json["elapsed_ms"].is_number());
206    }
207
208    #[test]
209    fn latest_schema_version_returns_zero_for_empty_db() {
210        let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
211        conn.execute_batch("CREATE TABLE refinery_schema_history (version INTEGER NOT NULL);")
212            .expect("failed to create table");
213
214        let version = latest_schema_version(&conn).expect("latest_schema_version failed");
215        assert_eq!(version, 0u32, "empty db must return schema_version 0");
216    }
217
218    #[test]
219    fn latest_schema_version_returns_max_version() {
220        let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
221        conn.execute_batch(
222            "CREATE TABLE refinery_schema_history (version INTEGER NOT NULL);
223             INSERT INTO refinery_schema_history VALUES (1);
224             INSERT INTO refinery_schema_history VALUES (3);
225             INSERT INTO refinery_schema_history VALUES (2);",
226        )
227        .expect("failed to populate table");
228
229        let version = latest_schema_version(&conn).expect("latest_schema_version failed");
230        assert_eq!(version, 3u32, "must return the highest version present");
231    }
232
233    #[test]
234    fn init_default_dim_matches_the_registered_setting_default() {
235        // `init` stamps `schema_meta.dim` from `DEFAULT_EMBEDDING_DIM`, while
236        // `config doctor` advertises the default of the `embedding.dim` key.
237        // Asserting the two against each other — rather than against a literal
238        // repeated here — means this test keeps its meaning after the next
239        // change instead of becoming a third place to update.
240        let registered = crate::config::SETTING_KEYS
241            .iter()
242            .find(|entry| entry.key == "embedding.dim")
243            .and_then(|entry| entry.default)
244            .expect("embedding.dim must be registered with a literal default");
245        assert_eq!(
246            registered.parse::<usize>().ok(),
247            Some(crate::constants::DEFAULT_EMBEDDING_DIM),
248            "config doctor would advertise {registered} while init stamps {}",
249            crate::constants::DEFAULT_EMBEDDING_DIM
250        );
251    }
252
253    #[test]
254    fn init_default_dim_is_inside_the_accepted_range() {
255        // A default outside the range would be rejected by the very resolver
256        // that is supposed to fall back to it, leaving the dim unresolved.
257        assert!(
258            crate::constants::EMBEDDING_DIM_RANGE
259                .contains(&crate::constants::DEFAULT_EMBEDDING_DIM),
260            "default dim must sit inside EMBEDDING_DIM_RANGE"
261        );
262    }
263
264    #[test]
265    fn init_response_namespace_aligned_with_schema() {
266        // Verify namespace field survives round-trip serialization with correct value.
267        let resp = InitResponse {
268            db_path: "/tmp/x.sqlite".to_string(),
269            schema_version: 6,
270            model: crate::constants::SQLITE_GRAPHRAG_VERSION.to_string(),
271            dim: crate::constants::DEFAULT_EMBEDDING_DIM,
272            namespace: "my-project".to_string(),
273            status: "ok".to_string(),
274            elapsed_ms: 0,
275        };
276        let json = serde_json::to_value(&resp).expect("serialization failed");
277        assert_eq!(json["namespace"], "my-project");
278    }
279}