Skip to main content

sqlite_graphrag/commands/
read.rs

1//! Handler for the `read` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_ro;
8use crate::storage::memories;
9use serde::Serialize;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n  \
13    # Read a memory by name (positional)\n  \
14    sqlite-graphrag read onboarding\n\n  \
15    # Read using the named flag form\n  \
16    sqlite-graphrag read --name onboarding\n\n  \
17    # Read from a specific namespace\n  \
18    sqlite-graphrag read onboarding --namespace my-project")]
19pub struct ReadArgs {
20    /// Memory name as a positional argument. Alternative to `--name`.
21    #[arg(
22        value_name = "NAME",
23        conflicts_with = "name",
24        help = "Memory name (kebab-case slug); alternative to --name"
25    )]
26    pub name_positional: Option<String>,
27    /// Memory name to read. Returns NotFound (exit 4) if missing or soft-deleted.
28    #[arg(long)]
29    pub name: Option<String>,
30    #[arg(
31        long,
32        help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
33    )]
34    pub namespace: Option<String>,
35    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
36    pub json: bool,
37    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
38    pub db: Option<String>,
39}
40
41#[derive(Serialize)]
42struct ReadResponse {
43    /// Canonical storage field. Preserved for compatibility with v2.0.0 clients.
44    id: i64,
45    /// Semantic alias of `id` for the contract documented in SKILL.md and AGENT_PROTOCOL.md.
46    memory_id: i64,
47    namespace: String,
48    name: String,
49    /// Semantic alias of `memory_type` for the documented contract.
50    #[serde(rename = "type")]
51    type_alias: String,
52    memory_type: String,
53    description: String,
54    body: String,
55    body_hash: String,
56    session_id: Option<String>,
57    source: String,
58    metadata: serde_json::Value,
59    /// Most recent memory version, useful for optimistic control via `--expected-updated-at`.
60    version: i64,
61    created_at: i64,
62    /// RFC 3339 UTC timestamp parallel to `created_at` for ISO 8601 parsers.
63    created_at_iso: String,
64    updated_at: i64,
65    /// RFC 3339 UTC timestamp parallel to `updated_at` for ISO 8601 parsers.
66    updated_at_iso: String,
67    /// Total execution time in milliseconds from handler start to serialisation.
68    elapsed_ms: u64,
69}
70
71fn epoch_to_iso(epoch: i64) -> String {
72    crate::tz::epoch_to_iso(epoch)
73}
74
75pub fn run(args: ReadArgs) -> Result<(), AppError> {
76    let start = std::time::Instant::now();
77    // Resolve name from positional or --name flag; both are optional, at least one is required.
78    let name = args.name_positional.or(args.name).ok_or_else(|| {
79        AppError::Validation("name required: pass as positional argument or via --name".to_string())
80    })?;
81    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
82    let paths = AppPaths::resolve(args.db.as_deref())?;
83    crate::storage::connection::ensure_db_ready(&paths)?;
84    let conn = open_ro(&paths.db)?;
85
86    match memories::read_by_name(&conn, &namespace, &name)? {
87        Some(row) => {
88            // Resolve current version via memory_versions table (highest version for this memory_id).
89            let version: i64 = conn
90                .query_row(
91                    "SELECT COALESCE(MAX(version), 1) FROM memory_versions WHERE memory_id=?1",
92                    rusqlite::params![row.id],
93                    |r| r.get(0),
94                )
95                .unwrap_or(1);
96
97            let response = ReadResponse {
98                id: row.id,
99                memory_id: row.id,
100                namespace: row.namespace,
101                name: row.name,
102                type_alias: row.memory_type.clone(),
103                memory_type: row.memory_type,
104                description: row.description,
105                body: row.body,
106                body_hash: row.body_hash,
107                session_id: row.session_id,
108                source: row.source,
109                metadata: serde_json::from_str::<serde_json::Value>(&row.metadata)
110                    .unwrap_or(serde_json::Value::Null),
111                version,
112                created_at: row.created_at,
113                created_at_iso: epoch_to_iso(row.created_at),
114                updated_at: row.updated_at,
115                updated_at_iso: epoch_to_iso(row.updated_at),
116                elapsed_ms: start.elapsed().as_millis() as u64,
117            };
118            output::emit_json(&response)?;
119        }
120        None => {
121            return Err(AppError::NotFound(errors_msg::memory_not_found(
122                &name, &namespace,
123            )))
124        }
125    }
126
127    Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn epoch_to_iso_converts_zero_to_unix_epoch() {
136        let result = epoch_to_iso(0);
137        assert!(
138            result.starts_with("1970-01-01T00:00:00"),
139            "epoch 0 must map to 1970-01-01T00:00:00, got: {result}"
140        );
141    }
142
143    #[test]
144    fn epoch_to_iso_converts_known_timestamp() {
145        let result = epoch_to_iso(1_705_320_000);
146        assert!(
147            result.starts_with("2024-01-15"),
148            "timestamp 1705320000 must map to 2024-01-15, got: {result}"
149        );
150    }
151
152    #[test]
153    fn epoch_to_iso_returns_fallback_for_invalid_negative_epoch() {
154        let result = epoch_to_iso(i64::MIN);
155        assert!(
156            !result.is_empty(),
157            "must return a non-empty string even for invalid epoch"
158        );
159    }
160
161    #[test]
162    fn read_response_serializes_id_and_memory_id_aliases() {
163        let resp = ReadResponse {
164            id: 42,
165            memory_id: 42,
166            namespace: "global".to_string(),
167            name: "my-mem".to_string(),
168            type_alias: "fact".to_string(),
169            memory_type: "fact".to_string(),
170            description: "desc".to_string(),
171            body: "body".to_string(),
172            body_hash: "abc123".to_string(),
173            session_id: None,
174            source: "agent".to_string(),
175            metadata: serde_json::json!({}),
176            version: 1,
177            created_at: 1_705_320_000,
178            created_at_iso: "2024-01-15T12:00:00Z".to_string(),
179            updated_at: 1_705_320_000,
180            updated_at_iso: "2024-01-15T12:00:00Z".to_string(),
181            elapsed_ms: 5,
182        };
183
184        let json = serde_json::to_value(&resp).expect("serialization failed");
185        assert_eq!(json["id"], 42);
186        assert_eq!(json["memory_id"], 42);
187        assert_eq!(json["type"], "fact");
188        assert_eq!(json["memory_type"], "fact");
189        assert_eq!(json["elapsed_ms"], 5u64);
190        assert!(
191            json["session_id"].is_null(),
192            "session_id None must serialize as null"
193        );
194        // metadata must serialize as a JSON object, not as an escaped string
195        assert!(
196            json["metadata"].is_object(),
197            "metadata must be a JSON object"
198        );
199    }
200
201    #[test]
202    fn read_response_session_id_some_serializes_string() {
203        let resp = ReadResponse {
204            id: 1,
205            memory_id: 1,
206            namespace: "global".to_string(),
207            name: "mem".to_string(),
208            type_alias: "skill".to_string(),
209            memory_type: "skill".to_string(),
210            description: "d".to_string(),
211            body: "b".to_string(),
212            body_hash: "h".to_string(),
213            session_id: Some("sess-123".to_string()),
214            source: "agent".to_string(),
215            metadata: serde_json::json!({}),
216            version: 2,
217            created_at: 0,
218            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
219            updated_at: 0,
220            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
221            elapsed_ms: 0,
222        };
223
224        let json = serde_json::to_value(&resp).expect("serialization failed");
225        assert_eq!(json["session_id"], "sess-123");
226    }
227
228    #[test]
229    fn read_response_elapsed_ms_is_present() {
230        let resp = ReadResponse {
231            id: 7,
232            memory_id: 7,
233            namespace: "ns".to_string(),
234            name: "n".to_string(),
235            type_alias: "procedure".to_string(),
236            memory_type: "procedure".to_string(),
237            description: "d".to_string(),
238            body: "b".to_string(),
239            body_hash: "h".to_string(),
240            session_id: None,
241            source: "agent".to_string(),
242            metadata: serde_json::json!({}),
243            version: 3,
244            created_at: 1000,
245            created_at_iso: "1970-01-01T00:16:40Z".to_string(),
246            updated_at: 2000,
247            updated_at_iso: "1970-01-01T00:33:20Z".to_string(),
248            elapsed_ms: 123,
249        };
250
251        let json = serde_json::to_value(&resp).expect("serialization failed");
252        assert_eq!(json["elapsed_ms"], 123u64);
253        assert!(json["created_at_iso"].is_string());
254        assert!(json["updated_at_iso"].is_string());
255    }
256
257    #[test]
258    fn read_response_metadata_object_not_escaped_string() {
259        // P2-A: metadata must serialize as a JSON object, not as an escaped string.
260        let resp = ReadResponse {
261            id: 3,
262            memory_id: 3,
263            namespace: "ns".to_string(),
264            name: "meta-test".to_string(),
265            type_alias: "fact".to_string(),
266            memory_type: "fact".to_string(),
267            description: "d".to_string(),
268            body: "b".to_string(),
269            body_hash: "h".to_string(),
270            session_id: None,
271            source: "agent".to_string(),
272            metadata: serde_json::json!({"key": "value", "number": 42}),
273            version: 1,
274            created_at: 0,
275            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
276            updated_at: 0,
277            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
278            elapsed_ms: 1,
279        };
280
281        let json = serde_json::to_value(&resp).expect("serialization failed");
282        // Must be object, not a JSON string containing escaped JSON.
283        assert!(json["metadata"].is_object());
284        assert_eq!(json["metadata"]["key"], "value");
285        assert_eq!(json["metadata"]["number"], 42);
286    }
287
288    #[test]
289    fn read_response_metadata_fallback_to_null_for_invalid_json() {
290        // P2-A: fallback when metadata is an invalid string.
291        let raw = "invalid-json{{{";
292        let parsed =
293            serde_json::from_str::<serde_json::Value>(raw).unwrap_or(serde_json::Value::Null);
294        assert!(parsed.is_null());
295    }
296}