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 action: String,
69 change_reason: String,
70 changed_by: Option<String>,
71 created_at: i64,
72 created_at_iso: String,
73}
74
75fn change_reason_to_action(reason: &str) -> String {
79 match reason {
80 "create" => "created",
81 "edit" => "edited",
82 "update" => "updated",
83 "rename" => "renamed",
84 "restore" => "restored",
85 "merge" => "merged",
86 "forget" => "forgotten",
87 other => other,
88 }
89 .to_string()
90}
91
92#[derive(Serialize)]
93struct HistoryResponse {
94 name: String,
95 namespace: String,
96 deleted: bool,
99 versions: Vec<HistoryVersion>,
100 elapsed_ms: u64,
102}
103
104pub fn run(args: HistoryArgs) -> Result<(), AppError> {
105 let start = std::time::Instant::now();
106 let name = args.name_positional.or(args.name).ok_or_else(|| {
108 AppError::Validation("name required: pass as positional argument or via --name".to_string())
109 })?;
110 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
111 let paths = AppPaths::resolve(args.db.as_deref())?;
112 crate::storage::connection::ensure_db_ready(&paths)?;
113 let conn = open_ro(&paths.db)?;
114
115 let row: Option<(i64, Option<i64>)> = conn
119 .query_row(
120 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
121 params![namespace, name],
122 |r| Ok((r.get(0)?, r.get(1)?)),
123 )
124 .optional()?;
125 let (memory_id, deleted_at) =
126 row.ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
127 let deleted = deleted_at.is_some();
128
129 let mut stmt = conn.prepare(
130 "SELECT version, name, type, description, body, metadata,
131 change_reason, changed_by, created_at
132 FROM memory_versions
133 WHERE memory_id = ?1
134 ORDER BY version ASC",
135 )?;
136
137 let no_body = args.no_body;
138 let versions = stmt
139 .query_map(params![memory_id], |r| {
140 let created_at: i64 = r.get(8)?;
141 let created_at_iso = crate::tz::epoch_to_iso(created_at);
142 let body_str: String = r.get(4)?;
143 let metadata_str: String = r.get(5)?;
144 let metadata_value: serde_json::Value = serde_json::from_str(&metadata_str)
145 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
146 let change_reason: String = r.get(6)?;
147 let action = change_reason_to_action(&change_reason);
148 Ok(HistoryVersion {
149 version: r.get(0)?,
150 name: r.get(1)?,
151 memory_type: r.get(2)?,
152 description: r.get(3)?,
153 body: if no_body { None } else { Some(body_str) },
154 metadata: metadata_value,
155 action,
156 change_reason,
157 changed_by: r.get(7)?,
158 created_at,
159 created_at_iso,
160 })
161 })?
162 .collect::<Result<Vec<_>, _>>()?;
163
164 output::emit_json(&HistoryResponse {
165 name,
166 namespace,
167 deleted,
168 versions,
169 elapsed_ms: start.elapsed().as_millis() as u64,
170 })?;
171
172 Ok(())
173}
174
175#[cfg(test)]
176mod tests {
177 use super::change_reason_to_action;
178
179 #[test]
181 fn change_reason_create_maps_to_created() {
182 assert_eq!(change_reason_to_action("create"), "created");
183 }
184
185 #[test]
186 fn change_reason_edit_maps_to_edited() {
187 assert_eq!(change_reason_to_action("edit"), "edited");
188 }
189
190 #[test]
191 fn change_reason_rename_maps_to_renamed() {
192 assert_eq!(change_reason_to_action("rename"), "renamed");
193 }
194
195 #[test]
196 fn change_reason_restore_maps_to_restored() {
197 assert_eq!(change_reason_to_action("restore"), "restored");
198 }
199
200 #[test]
201 fn change_reason_merge_maps_to_merged() {
202 assert_eq!(change_reason_to_action("merge"), "merged");
203 }
204
205 #[test]
206 fn change_reason_forget_maps_to_forgotten() {
207 assert_eq!(change_reason_to_action("forget"), "forgotten");
208 }
209
210 #[test]
211 fn change_reason_unknown_passes_through() {
212 assert_eq!(change_reason_to_action("custom-action"), "custom-action");
213 }
214
215 #[test]
216 fn epoch_zero_yields_valid_iso() {
217 let iso = crate::tz::epoch_to_iso(0);
219 assert!(iso.starts_with("1970-01-01T00:00:00"), "got: {iso}");
220 assert!(iso.contains("00:00"), "must contain offset, got: {iso}");
221 }
222
223 #[test]
224 fn typical_epoch_yields_iso_rfc3339() {
225 let iso = crate::tz::epoch_to_iso(1_745_000_000);
226 assert!(!iso.is_empty(), "created_at_iso must not be empty");
227 assert!(iso.contains('T'), "created_at_iso must contain T separator");
228 assert!(
230 iso.contains('+') || iso.contains('-'),
231 "must contain offset sign, got: {iso}"
232 );
233 }
234
235 #[test]
236 fn invalid_epoch_returns_fallback() {
237 let iso = crate::tz::epoch_to_iso(i64::MIN);
238 assert!(
239 !iso.is_empty(),
240 "invalid epoch must return non-empty fallback"
241 );
242 }
243}