Skip to main content

sqlite_graphrag/commands/
export.rs

1//! Handler for the `export` CLI subcommand.
2
3use crate::cli::MemoryType;
4use crate::errors::AppError;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_ro;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n  \
12    # Export all memories as NDJSON\n  \
13    sqlite-graphrag export\n\n  \
14    # Export only decision memories from a namespace\n  \
15    sqlite-graphrag export --type decision --namespace my-project\n\n  \
16    # Export including soft-deleted memories\n  \
17    sqlite-graphrag export --include-deleted\n\n  \
18    # Pipe to file for backup\n  \
19    sqlite-graphrag export > backup.ndjson")]
20pub struct ExportArgs {
21    /// Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global).
22    #[arg(
23        long,
24        help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
25    )]
26    pub namespace: Option<String>,
27    /// Filter by memory type.
28    #[arg(long, value_enum)]
29    pub r#type: Option<MemoryType>,
30    /// Include soft-deleted memories in the export.
31    #[arg(long, default_value_t = false)]
32    pub include_deleted: bool,
33    /// Maximum number of memories to export (default: 100000).
34    #[arg(long, default_value_t = 100_000)]
35    pub limit: usize,
36    /// Offset for pagination.
37    #[arg(long, default_value_t = 0)]
38    pub offset: usize,
39    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
40    pub json: bool,
41    /// Path to graphrag.sqlite (overrides SQLITE_GRAPHRAG_DB_PATH and default CWD).
42    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
43    pub db: Option<String>,
44}
45
46#[derive(Serialize)]
47struct ExportMemoryLine {
48    name: String,
49    r#type: String,
50    memory_type: String,
51    description: String,
52    body: String,
53    namespace: String,
54    created_at_iso: String,
55    updated_at_iso: String,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    deleted_at_iso: Option<String>,
58}
59
60#[derive(Serialize)]
61struct ExportSummary {
62    summary: bool,
63    exported: usize,
64    namespace: String,
65    elapsed_ms: u64,
66}
67
68/// Exports memories as NDJSON (one JSON line per memory, followed by a summary line).
69pub fn run(args: ExportArgs) -> Result<(), AppError> {
70    let start = std::time::Instant::now();
71    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
72    let paths = AppPaths::resolve(args.db.as_deref())?;
73    crate::storage::connection::ensure_db_ready(&paths)?;
74    let conn = open_ro(&paths.db)?;
75
76    let deleted_filter = if args.include_deleted {
77        ""
78    } else {
79        "AND m.deleted_at IS NULL"
80    };
81
82    let limit_i64 = args.limit as i64;
83    let offset_i64 = args.offset as i64;
84    let type_str: Option<String> = args.r#type.map(|t| t.as_str().to_string());
85
86    let rows = fetch_rows(
87        &conn,
88        &namespace,
89        &type_str,
90        deleted_filter,
91        limit_i64,
92        offset_i64,
93    )?;
94
95    let exported = rows.len();
96    for line in &rows {
97        output::emit_json_compact(line)?;
98    }
99
100    output::emit_json_compact(&ExportSummary {
101        summary: true,
102        exported,
103        namespace: namespace.clone(),
104        elapsed_ms: start.elapsed().as_millis() as u64,
105    })?;
106
107    Ok(())
108}
109
110fn fetch_rows(
111    conn: &rusqlite::Connection,
112    namespace: &str,
113    type_str: &Option<String>,
114    deleted_filter: &str,
115    limit: i64,
116    offset: i64,
117) -> Result<Vec<ExportMemoryLine>, AppError> {
118    let rows = if let Some(t) = type_str {
119        let sql = format!(
120            "SELECT m.name, m.type, m.description, m.body, m.namespace, \
121                    m.created_at, m.updated_at, m.deleted_at \
122             FROM memories m \
123             WHERE m.namespace = ?1 {deleted_filter} AND m.type = ?2 \
124             ORDER BY m.name \
125             LIMIT ?3 OFFSET ?4"
126        );
127        let mut stmt = conn.prepare(&sql)?;
128        let result = stmt
129            .query_map(rusqlite::params![namespace, t, limit, offset], map_row)?
130            .collect::<Result<Vec<_>, _>>()?;
131        result
132    } else {
133        let sql = format!(
134            "SELECT m.name, m.type, m.description, m.body, m.namespace, \
135                    m.created_at, m.updated_at, m.deleted_at \
136             FROM memories m \
137             WHERE m.namespace = ?1 {deleted_filter} \
138             ORDER BY m.name \
139             LIMIT ?2 OFFSET ?3"
140        );
141        let mut stmt = conn.prepare(&sql)?;
142        let result = stmt
143            .query_map(rusqlite::params![namespace, limit, offset], map_row)?
144            .collect::<Result<Vec<_>, _>>()?;
145        result
146    };
147    Ok(rows)
148}
149
150fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ExportMemoryLine> {
151    let memory_type_val: String = row.get(1)?;
152    Ok(ExportMemoryLine {
153        name: row.get(0)?,
154        r#type: memory_type_val.clone(),
155        memory_type: memory_type_val,
156        description: row.get(2)?,
157        body: row.get(3)?,
158        namespace: row.get(4)?,
159        created_at_iso: crate::tz::epoch_to_iso(row.get::<_, i64>(5)?),
160        updated_at_iso: crate::tz::epoch_to_iso(row.get::<_, i64>(6)?),
161        deleted_at_iso: row.get::<_, Option<i64>>(7)?.map(crate::tz::epoch_to_iso),
162    })
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn export_line_emits_both_type_and_memory_type() {
171        let line = ExportMemoryLine {
172            name: "test".to_string(),
173            r#type: "document".to_string(),
174            memory_type: "document".to_string(),
175            description: "desc".to_string(),
176            body: "body".to_string(),
177            namespace: "global".to_string(),
178            created_at_iso: "2025-01-01T00:00:00Z".to_string(),
179            updated_at_iso: "2025-01-01T00:00:00Z".to_string(),
180            deleted_at_iso: None,
181        };
182        let json = serde_json::to_value(&line).unwrap();
183        assert_eq!(json["type"], "document");
184        assert_eq!(json["memory_type"], "document");
185    }
186}