sqlite_graphrag/commands/
history.rs1use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_ro;
8use rusqlite::params;
9use rusqlite::OptionalExtension;
10use serde::Serialize;
11
12#[derive(clap::Args)]
13#[command(after_long_help = "EXAMPLES:\n \
14 # List all versions of a memory (positional form)\n \
15 sqlite-graphrag history onboarding\n\n \
16 # List versions using the named flag form\n \
17 sqlite-graphrag history --name onboarding\n\n \
18 # Omit body content to reduce response size\n \
19 sqlite-graphrag history onboarding --no-body")]
20pub struct HistoryArgs {
21 #[arg(
23 value_name = "NAME",
24 conflicts_with = "name",
25 help = "Memory name whose version history to return; alternative to --name"
26 )]
27 pub name_positional: Option<String>,
28 #[arg(long)]
31 pub name: Option<String>,
32 #[arg(long, default_value = "global", help = "Namespace to query")]
34 pub namespace: Option<String>,
35 #[arg(
37 long,
38 default_value_t = false,
39 help = "Omit body content from response"
40 )]
41 pub no_body: bool,
42 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
43 pub json: bool,
44 #[arg(
46 long,
47 env = "SQLITE_GRAPHRAG_DB_PATH",
48 help = "Path to graphrag.sqlite"
49 )]
50 pub db: Option<String>,
51}
52
53#[derive(Serialize)]
54struct HistoryVersion {
55 version: i64,
56 name: String,
57 #[serde(rename = "type")]
58 memory_type: String,
59 description: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 body: Option<String>,
62 metadata: serde_json::Value,
63 change_reason: String,
64 changed_by: Option<String>,
65 created_at: i64,
66 created_at_iso: String,
67}
68
69#[derive(Serialize)]
70struct HistoryResponse {
71 name: String,
72 namespace: String,
73 deleted: bool,
76 versions: Vec<HistoryVersion>,
77 elapsed_ms: u64,
79}
80
81pub fn run(args: HistoryArgs) -> Result<(), AppError> {
82 let start = std::time::Instant::now();
83 let name = args.name_positional.or(args.name).ok_or_else(|| {
85 AppError::Validation("name required: pass as positional argument or via --name".to_string())
86 })?;
87 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
88 let paths = AppPaths::resolve(args.db.as_deref())?;
89 crate::storage::connection::ensure_db_ready(&paths)?;
90 let conn = open_ro(&paths.db)?;
91
92 let row: Option<(i64, Option<i64>)> = conn
96 .query_row(
97 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
98 params![namespace, name],
99 |r| Ok((r.get(0)?, r.get(1)?)),
100 )
101 .optional()?;
102 let (memory_id, deleted_at) =
103 row.ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
104 let deleted = deleted_at.is_some();
105
106 let mut stmt = conn.prepare(
107 "SELECT version, name, type, description, body, metadata,
108 change_reason, changed_by, created_at
109 FROM memory_versions
110 WHERE memory_id = ?1
111 ORDER BY version ASC",
112 )?;
113
114 let no_body = args.no_body;
115 let versions = stmt
116 .query_map(params![memory_id], |r| {
117 let created_at: i64 = r.get(8)?;
118 let created_at_iso = crate::tz::epoch_to_iso(created_at);
119 let body_str: String = r.get(4)?;
120 let metadata_str: String = r.get(5)?;
121 let metadata_value: serde_json::Value = serde_json::from_str(&metadata_str)
122 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
123 Ok(HistoryVersion {
124 version: r.get(0)?,
125 name: r.get(1)?,
126 memory_type: r.get(2)?,
127 description: r.get(3)?,
128 body: if no_body { None } else { Some(body_str) },
129 metadata: metadata_value,
130 change_reason: r.get(6)?,
131 changed_by: r.get(7)?,
132 created_at,
133 created_at_iso,
134 })
135 })?
136 .collect::<Result<Vec<_>, _>>()?;
137
138 output::emit_json(&HistoryResponse {
139 name,
140 namespace,
141 deleted,
142 versions,
143 elapsed_ms: start.elapsed().as_millis() as u64,
144 })?;
145
146 Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151 #[test]
152 fn epoch_zero_yields_valid_iso() {
153 let iso = crate::tz::epoch_to_iso(0);
155 assert!(iso.starts_with("1970-01-01T00:00:00"), "got: {iso}");
156 assert!(iso.contains("00:00"), "must contain offset, got: {iso}");
157 }
158
159 #[test]
160 fn typical_epoch_yields_iso_rfc3339() {
161 let iso = crate::tz::epoch_to_iso(1_745_000_000);
162 assert!(!iso.is_empty(), "created_at_iso must not be empty");
163 assert!(iso.contains('T'), "created_at_iso must contain T separator");
164 assert!(
166 iso.contains('+') || iso.contains('-'),
167 "must contain offset sign, got: {iso}"
168 );
169 }
170
171 #[test]
172 fn invalid_epoch_returns_fallback() {
173 let iso = crate::tz::epoch_to_iso(i64::MIN);
174 assert!(
175 !iso.is_empty(),
176 "invalid epoch must return non-empty fallback"
177 );
178 }
179}