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(
33 long,
34 help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
35 )]
36 pub namespace: Option<String>,
37 #[arg(
39 long,
40 default_value_t = false,
41 help = "Omit body content from response"
42 )]
43 pub no_body: bool,
44 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
45 pub json: bool,
46 #[arg(
48 long,
49 env = "SQLITE_GRAPHRAG_DB_PATH",
50 help = "Path to graphrag.sqlite"
51 )]
52 pub db: Option<String>,
53}
54
55#[derive(Serialize)]
56struct HistoryVersion {
57 version: i64,
58 name: String,
59 #[serde(rename = "type")]
60 memory_type: String,
61 description: String,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 body: Option<String>,
64 metadata: serde_json::Value,
65 action: String,
71 change_reason: String,
72 changed_by: Option<String>,
73 created_at: i64,
74 created_at_iso: String,
75}
76
77fn change_reason_to_action(reason: &str) -> String {
81 match reason {
82 "create" => "created",
83 "edit" => "edited",
84 "update" => "updated",
85 "rename" => "renamed",
86 "restore" => "restored",
87 "merge" => "merged",
88 "forget" => "forgotten",
89 other => other,
90 }
91 .to_string()
92}
93
94#[derive(Serialize)]
95struct HistoryResponse {
96 name: String,
97 namespace: String,
98 deleted: bool,
101 versions: Vec<HistoryVersion>,
102 elapsed_ms: u64,
104}
105
106pub fn run(args: HistoryArgs) -> Result<(), AppError> {
107 let start = std::time::Instant::now();
108 let name = args.name_positional.or(args.name).ok_or_else(|| {
110 AppError::Validation("name required: pass as positional argument or via --name".to_string())
111 })?;
112 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
113 let paths = AppPaths::resolve(args.db.as_deref())?;
114 crate::storage::connection::ensure_db_ready(&paths)?;
115 let conn = open_ro(&paths.db)?;
116
117 let row: Option<(i64, Option<i64>)> = conn
121 .query_row(
122 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
123 params![namespace, name],
124 |r| Ok((r.get(0)?, r.get(1)?)),
125 )
126 .optional()?;
127 let (memory_id, deleted_at) =
128 row.ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
129 let deleted = deleted_at.is_some();
130
131 let mut stmt = conn.prepare(
132 "SELECT version, name, type, description, body, metadata,
133 change_reason, changed_by, created_at
134 FROM memory_versions
135 WHERE memory_id = ?1
136 ORDER BY version ASC",
137 )?;
138
139 let no_body = args.no_body;
140 let versions = stmt
141 .query_map(params![memory_id], |r| {
142 let created_at: i64 = r.get(8)?;
143 let created_at_iso = crate::tz::epoch_to_iso(created_at);
144 let body_str: String = r.get(4)?;
145 let metadata_str: String = r.get(5)?;
146 let metadata_value: serde_json::Value = serde_json::from_str(&metadata_str)
147 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
148 let change_reason: String = r.get(6)?;
149 let action = change_reason_to_action(&change_reason);
150 Ok(HistoryVersion {
151 version: r.get(0)?,
152 name: r.get(1)?,
153 memory_type: r.get(2)?,
154 description: r.get(3)?,
155 body: if no_body { None } else { Some(body_str) },
156 metadata: metadata_value,
157 action,
158 change_reason,
159 changed_by: r.get(7)?,
160 created_at,
161 created_at_iso,
162 })
163 })?
164 .collect::<Result<Vec<_>, _>>()?;
165
166 output::emit_json(&HistoryResponse {
167 name,
168 namespace,
169 deleted,
170 versions,
171 elapsed_ms: start.elapsed().as_millis() as u64,
172 })?;
173
174 Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::change_reason_to_action;
180
181 #[test]
183 fn change_reason_create_maps_to_created() {
184 assert_eq!(change_reason_to_action("create"), "created");
185 }
186
187 #[test]
188 fn change_reason_edit_maps_to_edited() {
189 assert_eq!(change_reason_to_action("edit"), "edited");
190 }
191
192 #[test]
193 fn change_reason_rename_maps_to_renamed() {
194 assert_eq!(change_reason_to_action("rename"), "renamed");
195 }
196
197 #[test]
198 fn change_reason_restore_maps_to_restored() {
199 assert_eq!(change_reason_to_action("restore"), "restored");
200 }
201
202 #[test]
203 fn change_reason_merge_maps_to_merged() {
204 assert_eq!(change_reason_to_action("merge"), "merged");
205 }
206
207 #[test]
208 fn change_reason_forget_maps_to_forgotten() {
209 assert_eq!(change_reason_to_action("forget"), "forgotten");
210 }
211
212 #[test]
213 fn change_reason_unknown_passes_through() {
214 assert_eq!(change_reason_to_action("custom-action"), "custom-action");
215 }
216
217 #[test]
218 fn epoch_zero_yields_valid_iso() {
219 let iso = crate::tz::epoch_to_iso(0);
221 assert!(iso.starts_with("1970-01-01T00:00:00"), "got: {iso}");
222 assert!(iso.contains("00:00"), "must contain offset, got: {iso}");
223 }
224
225 #[test]
226 fn typical_epoch_yields_iso_rfc3339() {
227 let iso = crate::tz::epoch_to_iso(1_745_000_000);
228 assert!(!iso.is_empty(), "created_at_iso must not be empty");
229 assert!(iso.contains('T'), "created_at_iso must contain T separator");
230 assert!(
232 iso.contains('+') || iso.contains('-'),
233 "must contain offset sign, got: {iso}"
234 );
235 }
236
237 #[test]
238 fn invalid_epoch_returns_fallback() {
239 let iso = crate::tz::epoch_to_iso(i64::MIN);
240 assert!(
241 !iso.is_empty(),
242 "invalid epoch must return non-empty fallback"
243 );
244 }
245}