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    /// Embedding model bound to this invocation, or `"none"` when none was
63    /// resolved.
64    ///
65    /// Until v1.2.4 this carried `SQLITE_GRAPHRAG_VERSION`, so a field named
66    /// `model` answered "1.2.4" while `init.schema.json` documented it as the
67    /// embedding model name. The version still reaches the database through
68    /// `schema_meta.sqlite-graphrag_version`, which is where it belongs.
69    model: String,
70    dim: usize,
71    /// Active namespace resolved during initialisation, aligned with the bilingual docs.
72    namespace: String,
73    status: String,
74    /// Total execution time in milliseconds from handler start to serialisation.
75    elapsed_ms: u64,
76}
77
78/// Run.
79///
80/// `embedding_model` is the model already resolved by the CLI bootstrap on the
81/// documented precedence (`--embedding-model` > XDG `embedding.model` > none);
82/// it is reported verbatim in the envelope so the caller sees what this
83/// invocation would actually embed with.
84pub fn run(
85    args: InitArgs,
86    backends: crate::cli::BackendChoice,
87    embedding_model: Option<&str>,
88) -> Result<(), AppError> {
89    let start = std::time::Instant::now();
90    let paths = AppPaths::resolve(args.db.as_deref())?;
91    paths.ensure_dirs()?;
92
93    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
94
95    let mut conn = open_rw(&paths.db)?;
96
97    apply_init_pragmas(&conn)?;
98
99    // Foreign keys must be off around the runner, not inside the migration
100    // files: see `storage::connection::run_migrations_with_foreign_keys_off`.
101    // `init` normally targets an empty file, where the ON DELETE CASCADE that
102    // `DROP TABLE` triggers has nothing to delete — which is precisely the
103    // reasoning that kept this defect invisible across nine migrations, so it
104    // is not a reason to leave the call unguarded.
105    crate::storage::connection::run_migrations_with_foreign_keys_off(
106        &mut conn,
107        "migration failed",
108    )?;
109
110    conn.execute_batch(&format!(
111        "PRAGMA user_version = {};",
112        crate::constants::SCHEMA_USER_VERSION
113    ))?;
114
115    // Defensive re-assertion: refinery may revert journal_mode during migrations.
116    ensure_wal_mode(&conn)?;
117
118    let schema_version = latest_schema_version(&conn)?;
119
120    conn.execute(
121        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', ?1)",
122        rusqlite::params![schema_version],
123    )?;
124    conn.execute(
125        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('model', ?1)",
126        rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
127    )?;
128    // G43: pre-v1.0.79 this hardcoded '384' as a literal, bypassing the
129    // active default (DEFAULT_EMBEDDING_DIM = 1024).
130    // INSERT OR IGNORE preserves the recorded dim on re-init of an existing
131    // database; the active dim (env > database > default) fills new ones.
132    conn.execute(
133        "INSERT OR IGNORE INTO schema_meta (key, value) VALUES ('dim', ?1)",
134        rusqlite::params![crate::constants::embedding_dim().to_string()],
135    )?;
136    conn.execute(
137        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT))",
138        [],
139    )?;
140    conn.execute(
141        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('sqlite-graphrag_version', ?1)",
142        rusqlite::params![crate::constants::SQLITE_GRAPHRAG_VERSION],
143    )?;
144    // Persist the resolved namespace so downstream tools can inspect it without re-resolving.
145    conn.execute(
146        "INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('namespace_initial', ?1)",
147        rusqlite::params![namespace],
148    )?;
149
150    output::emit_progress_i18n(
151        "Validating embedding backend...",
152        "Validando backend de embedding...",
153    );
154
155    // GAP-INIT-EMBEDDING-001 FIX (v1.0.89): init must succeed without LLM.
156    // Schema, tables and FTS5 are created above; the smoke test only validates
157    // that the embedding subprocess is reachable. When it is not (OAuth expired,
158    // CLI missing), init still succeeds with dim from the database or default.
159    // ADR-0011: Validation errors (OAuth-only enforcement) are FATAL — propagate.
160    // v1.0.89 (GAP-EMBED-PROPAGATION): honour --llm-backend via embed_passage_with_choice.
161    let (dim, status) = match crate::embedder::embed_passage_with_embedding_choice(
162        &paths.models,
163        "smoke test",
164        backends,
165    ) {
166        Ok((v, _backend)) => (v.len(), "ok"),
167        Err(crate::errors::AppError::Validation(msg)) => {
168            return Err(crate::errors::AppError::Validation(msg))
169        }
170        Err(e) => {
171            tracing::warn!(target: "init", error = %e, "embedding smoke test failed; init continues without LLM validation");
172            (crate::constants::embedding_dim(), "ok_no_embedding")
173        }
174    };
175
176    output::emit_json(&InitResponse {
177        db_path: paths.db.display().to_string(),
178        schema_version,
179        model: embedding_model.unwrap_or("none").to_string(),
180        dim,
181        namespace,
182        status: status.to_string(),
183        elapsed_ms: start.elapsed().as_millis() as u64,
184    })?;
185
186    Ok(())
187}
188
189fn latest_schema_version(conn: &rusqlite::Connection) -> Result<u32, AppError> {
190    match conn.query_row(
191        "SELECT version FROM refinery_schema_history ORDER BY version DESC LIMIT 1",
192        [],
193        |row| row.get::<_, i64>(0),
194    ) {
195        Ok(version) => Ok(version.max(0) as u32),
196        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0),
197        Err(err) => Err(AppError::Database(err)),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn init_response_serializes_all_fields() {
207        let resp = InitResponse {
208            db_path: "/tmp/test.sqlite".to_string(),
209            schema_version: 6,
210            model: "qwen/qwen3-embedding-8b".to_string(),
211            dim: crate::constants::DEFAULT_EMBEDDING_DIM,
212            namespace: "global".to_string(),
213            status: "ok".to_string(),
214            elapsed_ms: 100,
215        };
216        let json = serde_json::to_value(&resp).expect("serialization failed");
217        assert_eq!(json["db_path"], "/tmp/test.sqlite");
218        assert_eq!(json["schema_version"], 6);
219        // Until v1.2.4 this asserted `SQLITE_GRAPHRAG_VERSION`, freezing the
220        // very divergence `init.schema.json` documented against: a field named
221        // `model` must carry the embedding model, never the CLI version.
222        assert_eq!(json["model"], "qwen/qwen3-embedding-8b");
223        assert_ne!(json["model"], crate::constants::SQLITE_GRAPHRAG_VERSION);
224        assert_eq!(json["dim"], crate::constants::DEFAULT_EMBEDDING_DIM);
225        assert_eq!(json["namespace"], "global");
226        assert_eq!(json["status"], "ok");
227        assert!(json["elapsed_ms"].is_number());
228    }
229
230    #[test]
231    fn latest_schema_version_returns_zero_for_empty_db() {
232        let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
233        conn.execute_batch("CREATE TABLE refinery_schema_history (version INTEGER NOT NULL);")
234            .expect("failed to create table");
235
236        let version = latest_schema_version(&conn).expect("latest_schema_version failed");
237        assert_eq!(version, 0u32, "empty db must return schema_version 0");
238    }
239
240    #[test]
241    fn latest_schema_version_returns_max_version() {
242        let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
243        conn.execute_batch(
244            "CREATE TABLE refinery_schema_history (version INTEGER NOT NULL);
245             INSERT INTO refinery_schema_history VALUES (1);
246             INSERT INTO refinery_schema_history VALUES (3);
247             INSERT INTO refinery_schema_history VALUES (2);",
248        )
249        .expect("failed to populate table");
250
251        let version = latest_schema_version(&conn).expect("latest_schema_version failed");
252        assert_eq!(version, 3u32, "must return the highest version present");
253    }
254
255    #[test]
256    fn init_default_dim_matches_the_registered_setting_default() {
257        // `init` stamps `schema_meta.dim` from `DEFAULT_EMBEDDING_DIM`, while
258        // `config doctor` advertises the default of the `embedding.dim` key.
259        // Asserting the two against each other — rather than against a literal
260        // repeated here — means this test keeps its meaning after the next
261        // change instead of becoming a third place to update.
262        let registered = crate::config::SETTING_KEYS
263            .iter()
264            .find(|entry| entry.key == "embedding.dim")
265            .and_then(|entry| entry.default)
266            .expect("embedding.dim must be registered with a literal default");
267        assert_eq!(
268            registered.parse::<usize>().ok(),
269            Some(crate::constants::DEFAULT_EMBEDDING_DIM),
270            "config doctor would advertise {registered} while init stamps {}",
271            crate::constants::DEFAULT_EMBEDDING_DIM
272        );
273    }
274
275    #[test]
276    fn init_default_dim_is_inside_the_accepted_range() {
277        // A default outside the range would be rejected by the very resolver
278        // that is supposed to fall back to it, leaving the dim unresolved.
279        assert!(
280            crate::constants::EMBEDDING_DIM_RANGE
281                .contains(&crate::constants::DEFAULT_EMBEDDING_DIM),
282            "default dim must sit inside EMBEDDING_DIM_RANGE"
283        );
284    }
285
286    #[test]
287    fn init_response_namespace_aligned_with_schema() {
288        // Verify namespace field survives round-trip serialization with correct value.
289        let resp = InitResponse {
290            db_path: "/tmp/x.sqlite".to_string(),
291            schema_version: 6,
292            model: "none".to_string(),
293            dim: crate::constants::DEFAULT_EMBEDDING_DIM,
294            namespace: "my-project".to_string(),
295            status: "ok".to_string(),
296            elapsed_ms: 0,
297        };
298        let json = serde_json::to_value(&resp).expect("serialization failed");
299        assert_eq!(json["namespace"], "my-project");
300    }
301}