sqlite_graphrag/commands/
export.rs1use 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.")]
31pub struct ExportArgs {
33 #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
35 pub namespace: Option<String>,
36 #[arg(long, value_enum)]
38 pub r#type: Option<MemoryType>,
39 #[arg(long, default_value_t = false)]
41 pub include_deleted: bool,
42 #[arg(long, default_value_t = DEFAULT_EXPORT_LIMIT, value_parser = crate::parsers::parse_list_limit_range)]
44 pub limit: usize,
45 #[arg(long, default_value_t = 0)]
47 pub offset: usize,
48 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
50 pub json: bool,
51 #[arg(long)]
53 pub db: Option<String>,
54}
55
56const 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
85pub 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 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 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
154fn 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
219fn 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}