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