sqlite_graphrag/commands/
history.rs1use crate::errors::AppError;
2use crate::i18n::erros;
3use crate::output;
4use crate::paths::AppPaths;
5use crate::storage::connection::open_ro;
6use rusqlite::params;
7use rusqlite::OptionalExtension;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11pub struct HistoryArgs {
12 #[arg(long)]
15 pub name: String,
16 #[arg(long, default_value = "global")]
17 pub namespace: Option<String>,
18 #[arg(long, help = "No-op; JSON is always emitted on stdout")]
19 pub json: bool,
20 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
21 pub db: Option<String>,
22}
23
24#[derive(Serialize)]
25struct HistoryVersion {
26 version: i64,
27 name: String,
28 #[serde(rename = "type")]
29 memory_type: String,
30 description: String,
31 body: String,
32 metadata: String,
33 change_reason: String,
34 changed_by: Option<String>,
35 created_at: i64,
36 created_at_iso: String,
37}
38
39#[derive(Serialize)]
40struct HistoryResponse {
41 name: String,
42 namespace: String,
43 deleted: bool,
46 versions: Vec<HistoryVersion>,
47 elapsed_ms: u64,
49}
50
51pub fn run(args: HistoryArgs) -> Result<(), AppError> {
52 let inicio = std::time::Instant::now();
53 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
54 let paths = AppPaths::resolve(args.db.as_deref())?;
55 if !paths.db.exists() {
56 return Err(AppError::NotFound(erros::banco_nao_encontrado(
57 &paths.db.display().to_string(),
58 )));
59 }
60 let conn = open_ro(&paths.db)?;
61
62 let row: Option<(i64, Option<i64>)> = conn
66 .query_row(
67 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
68 params![namespace, args.name],
69 |r| Ok((r.get(0)?, r.get(1)?)),
70 )
71 .optional()?;
72 let (memory_id, deleted_at) = row
73 .ok_or_else(|| AppError::NotFound(erros::memoria_nao_encontrada(&args.name, &namespace)))?;
74 let deleted = deleted_at.is_some();
75
76 let mut stmt = conn.prepare(
77 "SELECT version, name, type, description, body, metadata,
78 change_reason, changed_by, created_at
79 FROM memory_versions
80 WHERE memory_id = ?1
81 ORDER BY version ASC",
82 )?;
83
84 let versions = stmt
85 .query_map(params![memory_id], |r| {
86 let created_at: i64 = r.get(8)?;
87 let created_at_iso = crate::tz::epoch_para_iso(created_at);
88 Ok(HistoryVersion {
89 version: r.get(0)?,
90 name: r.get(1)?,
91 memory_type: r.get(2)?,
92 description: r.get(3)?,
93 body: r.get(4)?,
94 metadata: r.get(5)?,
95 change_reason: r.get(6)?,
96 changed_by: r.get(7)?,
97 created_at,
98 created_at_iso,
99 })
100 })?
101 .collect::<Result<Vec<_>, _>>()?;
102
103 output::emit_json(&HistoryResponse {
104 name: args.name,
105 namespace,
106 deleted,
107 versions,
108 elapsed_ms: inicio.elapsed().as_millis() as u64,
109 })?;
110
111 Ok(())
112}
113
114#[cfg(test)]
115mod testes {
116 #[test]
117 fn epoch_zero_gera_iso_valido() {
118 let iso = crate::tz::epoch_para_iso(0);
120 assert!(iso.starts_with("1970-01-01T00:00:00"), "obtido: {iso}");
121 assert!(iso.contains("00:00"), "deve conter offset, obtido: {iso}");
122 }
123
124 #[test]
125 fn epoch_tipico_gera_iso_rfc3339() {
126 let iso = crate::tz::epoch_para_iso(1_745_000_000);
127 assert!(!iso.is_empty(), "created_at_iso não deve ser vazio");
128 assert!(iso.contains('T'), "created_at_iso deve conter separador T");
129 assert!(
131 iso.contains('+') || iso.contains('-'),
132 "deve conter sinal de offset, obtido: {iso}"
133 );
134 }
135
136 #[test]
137 fn epoch_invalido_retorna_fallback() {
138 let iso = crate::tz::epoch_para_iso(i64::MIN);
139 assert!(
140 !iso.is_empty(),
141 "epoch inválido deve retornar fallback não-vazio"
142 );
143 }
144}