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\n\n\
20    STREAM CONTRACT (GAP-SG-215):\n  \
21    Output is one self-contained record per line, followed by one summary line.\n  \
22    A record line carries the record and nothing else. The summary line carries\n  \
23    the single agent-surface record for the whole stream and is never reshaped.\n\n  \
24    Per-record knobs act here: --select and --truncate-content.\n  \
25    Whole-set knobs are refused with exit 2 before the first line, because they\n  \
26    cannot mean anything per record: --count-only, --sort, --dedupe-by,\n  \
27    --max-output-bytes and --max-items. Narrow the query with --limit instead.\n  \
28    --filter is refused too: the summary counts what the QUERY returned, so a\n  \
29    predicate applied here would leave that count describing rows you never got.\n  \
30    Use --type and --namespace to narrow at the source.")]
31/// Export args.
32pub struct ExportArgs {
33    /// Namespace (flag / XDG namespace.default / global).
34    #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
35    pub namespace: Option<String>,
36    /// Filter by memory type.
37    #[arg(long, value_enum)]
38    pub r#type: Option<MemoryType>,
39    /// Include soft-deleted memories in the export.
40    #[arg(long, default_value_t = false)]
41    pub include_deleted: bool,
42    /// Maximum number of memories to export (default: 100000).
43    #[arg(long, default_value_t = DEFAULT_EXPORT_LIMIT, value_parser = crate::parsers::parse_list_limit_range)]
44    pub limit: usize,
45    /// Offset for pagination.
46    #[arg(long, default_value_t = 0)]
47    pub offset: usize,
48    /// Emit machine-readable JSON on stdout.
49    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
50    pub json: bool,
51    /// Path to graphrag.sqlite. Overrides the XDG `db.path` setting.
52    #[arg(long)]
53    pub db: Option<String>,
54}
55
56/// Page size `export` uses when the caller names none.
57///
58/// Named because GAP-SG-201 has to tell a ceiling the caller CHOSE from one a
59/// constant supplied, and comparing against a literal in two places is how those
60/// two facts drift apart.
61const DEFAULT_EXPORT_LIMIT: usize = 100_000;
62
63#[derive(Serialize)]
64struct ExportMemoryLine {
65    name: String,
66    r#type: String,
67    memory_type: String,
68    description: String,
69    body: String,
70    namespace: String,
71    created_at_iso: String,
72    updated_at_iso: String,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    deleted_at_iso: Option<String>,
75}
76
77#[derive(Serialize)]
78struct ExportSummary {
79    summary: bool,
80    exported: usize,
81    namespace: String,
82    elapsed_ms: u64,
83}
84
85/// Exports memories as NDJSON (one JSON line per memory, followed by a summary line).
86pub fn run(args: ExportArgs) -> Result<(), AppError> {
87    let start = std::time::Instant::now();
88    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
89    let paths = AppPaths::resolve(args.db.as_deref())?;
90    crate::storage::connection::ensure_db_ready(&paths)?;
91    let conn = open_ro(&paths.db)?;
92
93    let deleted_filter = if args.include_deleted {
94        ""
95    } else {
96        "AND m.deleted_at IS NULL"
97    };
98
99    let limit_i64 = args.limit as i64;
100    let offset_i64 = args.offset as i64;
101    let type_str: Option<String> = args.r#type.map(|t| t.as_str().to_string());
102
103    let rows = fetch_rows(
104        &conn,
105        &namespace,
106        &type_str,
107        deleted_filter,
108        limit_i64,
109        offset_i64,
110    )?;
111
112    // GAP-SG-201: declared BEFORE the first emission, because the output surface
113    // reads it while shaping each envelope. `export` paginates like `list` and
114    // `graph entities` do, and until now declared nothing — so `--filter` and
115    // `--count-only` here judged a page while reporting `query_limited: null`,
116    // the exact silence the ceiling exists to break. The default limit of 100 000
117    // is wider than most corpora, so this is usually a report and not a cut.
118    let total_count = count_rows(&conn, &namespace, &type_str, deleted_filter)?;
119    crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
120        applied: args.limit,
121        offset: args.offset,
122        source: if args.limit == DEFAULT_EXPORT_LIMIT {
123            crate::agent_surface::universe::CeilingSource::Default
124        } else {
125            crate::agent_surface::universe::CeilingSource::Flag
126        },
127        kind: crate::agent_surface::universe::CeilingKind::Pagination,
128        universe_total: Some(total_count),
129    });
130
131    let exported = rows.len();
132
133    // GAP-SG-215: resolve the whole stream's request BEFORE the first line goes
134    // out. `--select name export` used to emit three correct records and then
135    // exit 2 on the fourth, the summary — a refusal delivered on top of output
136    // the caller had already started consuming. Opening here means a refusal
137    // arrives with stdout still untouched.
138    open_stream(&rows)?;
139
140    for line in &rows {
141        output::emit_stream_record(line)?;
142    }
143
144    output::emit_stream_trailer(&ExportSummary {
145        summary: true,
146        exported,
147        namespace: namespace.clone(),
148        elapsed_ms: start.elapsed().as_millis() as u64,
149    })?;
150
151    Ok(())
152}
153
154/// Hands the surface a bounded prefix of the records it is about to shape.
155///
156/// The prefix is the memory bound, and it is not a small one to give up: a
157/// record measured ~24 KB as a `Value`, so materializing all of them at the
158/// default `--limit 100000` would cost roughly 2.4 GB to answer a question about
159/// which field names exist. `agent_surface::stream` receives the real row count
160/// alongside the prefix and declares the bound on the trailer.
161///
162/// Skipped entirely when no key needs resolving: without `--select` the
163/// vocabulary is never consulted, so serializing even one row would be work done
164/// for nobody. The refusals still run — `open` reaches them with an empty
165/// sample, which is exactly right, since none of them asks about field names.
166fn open_stream(rows: &[ExportMemoryLine]) -> Result<(), AppError> {
167    let surface = crate::agent_surface::get();
168    let sample = if surface.select.is_empty() {
169        Vec::new()
170    } else {
171        rows.iter()
172            .take(crate::agent_surface::stream::SAMPLE_RECORDS)
173            .map(serde_json::to_value)
174            .collect::<Result<Vec<_>, _>>()?
175    };
176    crate::agent_surface::stream::open(surface, &sample, rows.len())
177}
178
179fn fetch_rows(
180    conn: &rusqlite::Connection,
181    namespace: &str,
182    type_str: &Option<String>,
183    deleted_filter: &str,
184    limit: i64,
185    offset: i64,
186) -> Result<Vec<ExportMemoryLine>, AppError> {
187    let rows = if let Some(t) = type_str {
188        let sql = format!(
189            "SELECT m.name, m.type, m.description, m.body, m.namespace, \
190                    m.created_at, m.updated_at, m.deleted_at \
191             FROM memories m \
192             WHERE m.namespace = ?1 {deleted_filter} AND m.type = ?2 \
193             ORDER BY m.name \
194             LIMIT ?3 OFFSET ?4"
195        );
196        let mut stmt = conn.prepare(&sql)?;
197        let result = stmt
198            .query_map(rusqlite::params![namespace, t, limit, offset], map_row)?
199            .collect::<Result<Vec<_>, _>>()?;
200        result
201    } else {
202        let sql = format!(
203            "SELECT m.name, m.type, m.description, m.body, m.namespace, \
204                    m.created_at, m.updated_at, m.deleted_at \
205             FROM memories m \
206             WHERE m.namespace = ?1 {deleted_filter} \
207             ORDER BY m.name \
208             LIMIT ?2 OFFSET ?3"
209        );
210        let mut stmt = conn.prepare(&sql)?;
211        let result = stmt
212            .query_map(rusqlite::params![namespace, limit, offset], map_row)?
213            .collect::<Result<Vec<_>, _>>()?;
214        result
215    };
216    Ok(rows)
217}
218
219/// Counts the rows [`fetch_rows`] pages through, for the GAP-SG-201 ceiling.
220///
221/// The WHERE clauses mirror [`fetch_rows`] exactly, including `deleted_filter`.
222/// A divergence would declare a universe that describes a different set than the
223/// one being paged, which is worse than declaring none: the surface would refuse,
224/// or decline to refuse, on a comparison against the wrong number.
225fn count_rows(
226    conn: &rusqlite::Connection,
227    namespace: &str,
228    type_str: &Option<String>,
229    deleted_filter: &str,
230) -> Result<usize, AppError> {
231    let count: i64 = if let Some(t) = type_str {
232        let sql = format!(
233            "SELECT COUNT(*) FROM memories m \
234             WHERE m.namespace = ?1 {deleted_filter} AND m.type = ?2"
235        );
236        conn.query_row(&sql, rusqlite::params![namespace, t], |r| r.get(0))?
237    } else {
238        let sql =
239            format!("SELECT COUNT(*) FROM memories m WHERE m.namespace = ?1 {deleted_filter}");
240        conn.query_row(&sql, rusqlite::params![namespace], |r| r.get(0))?
241    };
242    Ok(usize::try_from(count).unwrap_or(0))
243}
244
245fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ExportMemoryLine> {
246    let memory_type_val: String = row.get(1)?;
247    Ok(ExportMemoryLine {
248        name: row.get(0)?,
249        r#type: memory_type_val.clone(),
250        memory_type: memory_type_val,
251        description: row.get(2)?,
252        body: row.get(3)?,
253        namespace: row.get(4)?,
254        created_at_iso: crate::tz::epoch_to_iso(row.get::<_, i64>(5)?),
255        updated_at_iso: crate::tz::epoch_to_iso(row.get::<_, i64>(6)?),
256        deleted_at_iso: row.get::<_, Option<i64>>(7)?.map(crate::tz::epoch_to_iso),
257    })
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn export_line_emits_both_type_and_memory_type() {
266        let line = ExportMemoryLine {
267            name: "test".to_string(),
268            r#type: "document".to_string(),
269            memory_type: "document".to_string(),
270            description: "desc".to_string(),
271            body: "body".to_string(),
272            namespace: "global".to_string(),
273            created_at_iso: "2025-01-01T00:00:00Z".to_string(),
274            updated_at_iso: "2025-01-01T00:00:00Z".to_string(),
275            deleted_at_iso: None,
276        };
277        let json = serde_json::to_value(&line).unwrap();
278        assert_eq!(json["type"], "document");
279        assert_eq!(json["memory_type"], "document");
280    }
281}