Skip to main content

sqlite_graphrag/commands/
history.rs

1use 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    /// Memory name as a positional argument. Alternative to `--name`.
13    #[arg(value_name = "NAME", conflicts_with = "name")]
14    pub name_positional: Option<String>,
15    /// Memory name whose version history will be returned. Includes soft-deleted memories
16    /// so that `restore --version <V>` workflow remains discoverable after `forget`.
17    #[arg(long)]
18    pub name: Option<String>,
19    #[arg(long, default_value = "global")]
20    pub namespace: Option<String>,
21    #[arg(long, help = "No-op; JSON is always emitted on stdout")]
22    pub json: bool,
23    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
24    pub db: Option<String>,
25}
26
27#[derive(Serialize)]
28struct HistoryVersion {
29    version: i64,
30    name: String,
31    #[serde(rename = "type")]
32    memory_type: String,
33    description: String,
34    body: String,
35    metadata: String,
36    change_reason: String,
37    changed_by: Option<String>,
38    created_at: i64,
39    created_at_iso: String,
40}
41
42#[derive(Serialize)]
43struct HistoryResponse {
44    name: String,
45    namespace: String,
46    /// True quando a memória está atualmente soft-deleted (forgotten).
47    /// Permite ao usuário descobrir a versão para `restore` mesmo após `forget`.
48    deleted: bool,
49    versions: Vec<HistoryVersion>,
50    /// Tempo total de execução em milissegundos desde início do handler até serialização.
51    elapsed_ms: u64,
52}
53
54pub fn run(args: HistoryArgs) -> Result<(), AppError> {
55    let inicio = std::time::Instant::now();
56    // Resolve name from positional or --name flag; both are optional, at least one is required.
57    let name = args.name_positional.or(args.name).ok_or_else(|| {
58        AppError::Validation("name required: pass as positional argument or via --name".to_string())
59    })?;
60    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
61    let paths = AppPaths::resolve(args.db.as_deref())?;
62    if !paths.db.exists() {
63        return Err(AppError::NotFound(erros::banco_nao_encontrado(
64            &paths.db.display().to_string(),
65        )));
66    }
67    let conn = open_ro(&paths.db)?;
68
69    // v1.0.22 P0: query direta SEM filtro deleted_at — history DEVE retornar versões
70    // de memórias forgotten para que o usuário descubra a versão em `restore`.
71    // O find_by_name antigo filtrava deleted_at IS NULL e gerava dead-end no workflow forget+restore.
72    let row: Option<(i64, Option<i64>)> = conn
73        .query_row(
74            "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
75            params![namespace, name],
76            |r| Ok((r.get(0)?, r.get(1)?)),
77        )
78        .optional()?;
79    let (memory_id, deleted_at) =
80        row.ok_or_else(|| AppError::NotFound(erros::memoria_nao_encontrada(&name, &namespace)))?;
81    let deleted = deleted_at.is_some();
82
83    let mut stmt = conn.prepare(
84        "SELECT version, name, type, description, body, metadata,
85                change_reason, changed_by, created_at
86         FROM memory_versions
87         WHERE memory_id = ?1
88         ORDER BY version ASC",
89    )?;
90
91    let versions = stmt
92        .query_map(params![memory_id], |r| {
93            let created_at: i64 = r.get(8)?;
94            let created_at_iso = crate::tz::epoch_para_iso(created_at);
95            Ok(HistoryVersion {
96                version: r.get(0)?,
97                name: r.get(1)?,
98                memory_type: r.get(2)?,
99                description: r.get(3)?,
100                body: r.get(4)?,
101                metadata: r.get(5)?,
102                change_reason: r.get(6)?,
103                changed_by: r.get(7)?,
104                created_at,
105                created_at_iso,
106            })
107        })?
108        .collect::<Result<Vec<_>, _>>()?;
109
110    output::emit_json(&HistoryResponse {
111        name,
112        namespace,
113        deleted,
114        versions,
115        elapsed_ms: inicio.elapsed().as_millis() as u64,
116    })?;
117
118    Ok(())
119}
120
121#[cfg(test)]
122mod testes {
123    #[test]
124    fn epoch_zero_gera_iso_valido() {
125        // epoch_para_iso usa chrono-tz com offset explícito (+00:00 para UTC)
126        let iso = crate::tz::epoch_para_iso(0);
127        assert!(iso.starts_with("1970-01-01T00:00:00"), "obtido: {iso}");
128        assert!(iso.contains("00:00"), "deve conter offset, obtido: {iso}");
129    }
130
131    #[test]
132    fn epoch_tipico_gera_iso_rfc3339() {
133        let iso = crate::tz::epoch_para_iso(1_745_000_000);
134        assert!(!iso.is_empty(), "created_at_iso não deve ser vazio");
135        assert!(iso.contains('T'), "created_at_iso deve conter separador T");
136        // Com UTC o offset é +00:00; verifica formato geral sem depender do fuso global
137        assert!(
138            iso.contains('+') || iso.contains('-'),
139            "deve conter sinal de offset, obtido: {iso}"
140        );
141    }
142
143    #[test]
144    fn epoch_invalido_retorna_fallback() {
145        let iso = crate::tz::epoch_para_iso(i64::MIN);
146        assert!(
147            !iso.is_empty(),
148            "epoch inválido deve retornar fallback não-vazio"
149        );
150    }
151}