Skip to main content

scone_core/
portability.rs

1//! Export/import (spec §8): your memory is portable, full stop.
2//!
3//! JSONL, one self-describing record per line. Fact provenance crosses
4//! stores via episode content hashes (ids are store-local; hashes are
5//! identity), and every importer path is idempotent — re-importing an
6//! export is a no-op, never a duplication (memory/bugs.md P-5).
7
8use std::collections::HashMap;
9
10use crate::Engine;
11use crate::auth::ScopedSpace;
12use crate::error::{Result, SconeError};
13
14#[derive(Debug, Default)]
15pub struct ImportReport {
16    pub episodes: usize,
17    pub deduplicated: usize,
18    pub facts: usize,
19    pub aliases: usize,
20}
21
22impl Engine {
23    /// Export one space as JSONL: episodes, entity aliases, then facts
24    /// (with full interval history and hash-based provenance).
25    pub fn export_jsonl(&self, space: &ScopedSpace) -> Result<String> {
26        let mut out = String::new();
27        let mut stmt = self.conn.prepare(
28            "SELECT kind, content, source, created_at, hash
29             FROM episodes WHERE space_id = ?1 ORDER BY id",
30        )?;
31        let rows = stmt.query_map([space.id()], |r| {
32            Ok(serde_json::json!({
33                "type": "episode",
34                "kind": r.get::<_, String>(0)?,
35                "content": r.get::<_, String>(1)?,
36                "source": r.get::<_, Option<String>>(2)?,
37                "created_at": r.get::<_, String>(3)?,
38                "hash": r.get::<_, String>(4)?,
39            }))
40        })?;
41        for row in rows {
42            out.push_str(&row?.to_string());
43            out.push('\n');
44        }
45        let mut stmt = self.conn.prepare(
46            "SELECT a.alias, en.canonical FROM entity_aliases a
47             JOIN entities en ON en.id = a.entity_id ORDER BY a.alias",
48        )?;
49        let rows = stmt.query_map([], |r| {
50            Ok(serde_json::json!({
51                "type": "alias",
52                "alias": r.get::<_, String>(0)?,
53                "canonical": r.get::<_, String>(1)?,
54            }))
55        })?;
56        for row in rows {
57            out.push_str(&row?.to_string());
58            out.push('\n');
59        }
60        let mut stmt = self.conn.prepare(
61            "SELECT en.canonical, f.predicate, f.object, f.confidence, f.valid_from,
62                    f.valid_until, f.status, f.status_reason,
63                    (SELECT json_group_array(e.hash) FROM fact_provenance fp
64                     JOIN episodes e ON e.id = fp.episode_id WHERE fp.fact_id = f.id)
65             FROM facts f JOIN entities en ON en.id = f.subject_entity
66             WHERE f.space_id = ?1 ORDER BY f.id",
67        )?;
68        let rows = stmt.query_map([space.id()], |r| {
69            let provenance: String = r.get(8)?;
70            Ok(serde_json::json!({
71                "type": "fact",
72                "subject": r.get::<_, String>(0)?,
73                "predicate": r.get::<_, String>(1)?,
74                "object": r.get::<_, String>(2)?,
75                "confidence": r.get::<_, f64>(3)?,
76                "valid_from": r.get::<_, String>(4)?,
77                "valid_until": r.get::<_, Option<String>>(5)?,
78                "status": r.get::<_, String>(6)?,
79                "status_reason": r.get::<_, Option<String>>(7)?,
80                "provenance_hashes": serde_json::from_str::<serde_json::Value>(&provenance)
81                    .unwrap_or_else(|_| serde_json::json!([])),
82            }))
83        })?;
84        for row in rows {
85            out.push_str(&row?.to_string());
86            out.push('\n');
87        }
88        Ok(out)
89    }
90
91    /// Import JSONL produced by [`Engine::export_jsonl`]. Idempotent.
92    pub fn import_jsonl(&mut self, space: &ScopedSpace, data: &str) -> Result<ImportReport> {
93        let mut report = ImportReport::default();
94        let mut records = Vec::new();
95        for (n, line) in data.lines().enumerate() {
96            if line.trim().is_empty() {
97                continue;
98            }
99            let value: serde_json::Value = serde_json::from_str(line)
100                .map_err(|e| SconeError::InvalidInput(format!("line {}: not JSON: {e}", n + 1)))?;
101            records.push(value);
102        }
103        // Pass 1: episodes (dedup via UNIQUE(space, hash) as always).
104        let mut hash_to_id: HashMap<String, i64> = HashMap::new();
105        for record in &records {
106            if record["type"] == "episode" {
107                let content = record["content"].as_str().unwrap_or_default();
108                let kind = record["kind"].as_str().unwrap_or("note");
109                let outcome = self.import_episode(
110                    space,
111                    kind,
112                    content,
113                    record["source"].as_str(),
114                    record["created_at"].as_str(),
115                )?;
116                let (id, fresh) = outcome;
117                if fresh {
118                    report.episodes += 1;
119                } else {
120                    report.deduplicated += 1;
121                }
122                hash_to_id.insert(blake3::hash(content.as_bytes()).to_hex().to_string(), id);
123            }
124        }
125        // Pass 2: aliases, then facts with hash-mapped provenance.
126        for record in &records {
127            match record["type"].as_str() {
128                Some("alias") => {
129                    let (Some(alias), Some(canonical)) =
130                        (record["alias"].as_str(), record["canonical"].as_str())
131                    else {
132                        continue;
133                    };
134                    let existed: i64 = self.conn.query_row(
135                        "SELECT count(*) FROM entity_aliases WHERE alias = ?1",
136                        [alias],
137                        |r| r.get(0),
138                    )?;
139                    self.add_entity_alias(alias, canonical)?;
140                    if existed == 0 {
141                        report.aliases += 1;
142                    }
143                }
144                Some("fact") if self.import_fact(space, record, &hash_to_id)? => {
145                    report.facts += 1;
146                }
147                _ => {}
148            }
149        }
150        Ok(report)
151    }
152}
153
154impl Engine {
155    /// Insert one exported fact if an identical one is not already present.
156    fn import_fact(
157        &mut self,
158        space: &ScopedSpace,
159        record: &serde_json::Value,
160        hash_to_id: &HashMap<String, i64>,
161    ) -> Result<bool> {
162        let field = |key: &str| -> Result<&str> {
163            record[key]
164                .as_str()
165                .ok_or_else(|| SconeError::InvalidInput(format!("fact record missing {key}")))
166        };
167        let subject = field("subject")?;
168        let predicate = field("predicate")?;
169        let object = field("object")?;
170        let valid_from = field("valid_from")?;
171        let status = field("status")?;
172        if !["active", "closed", "expired"].contains(&status) {
173            return Err(SconeError::InvalidInput(format!(
174                "fact status {status:?} is not one of active/closed/expired"
175            )));
176        }
177        let confidence = record["confidence"].as_f64().unwrap_or(0.5);
178        let valid_until = record["valid_until"].as_str();
179        let status_reason = record["status_reason"].as_str();
180
181        // Resolve provenance hashes to local episode ids before writing —
182        // a fact without provenance would violate I4.
183        let mut episode_ids = Vec::new();
184        if let Some(hashes) = record["provenance_hashes"].as_array() {
185            for h in hashes.iter().filter_map(|h| h.as_str()) {
186                let id = match hash_to_id.get(h) {
187                    Some(id) => *id,
188                    None => match self.conn.query_row(
189                        "SELECT id FROM episodes WHERE space_id = ?1 AND hash = ?2",
190                        rusqlite::params![space.id(), h],
191                        |r| r.get::<_, i64>(0),
192                    ) {
193                        Ok(id) => id,
194                        Err(rusqlite::Error::QueryReturnedNoRows) => continue,
195                        Err(e) => return Err(SconeError::Db(e)),
196                    },
197                };
198                episode_ids.push(id);
199            }
200        }
201        if episode_ids.is_empty() {
202            return Err(SconeError::InvalidInput(format!(
203                "fact ({subject} {predicate} {object}) references no importable \
204                 episode; import episodes first (I4)"
205            )));
206        }
207
208        let tx = self.conn.transaction()?;
209        let entity_id: i64 = {
210            let canonical = subject.trim().to_lowercase();
211            tx.execute(
212                "INSERT OR IGNORE INTO entities (canonical) VALUES (?1)",
213                [&canonical],
214            )?;
215            tx.query_row(
216                "SELECT id FROM entities WHERE canonical = ?1",
217                [&canonical],
218                |r| r.get(0),
219            )?
220        };
221        let exists: i64 = tx.query_row(
222            "SELECT count(*) FROM facts
223             WHERE space_id = ?1 AND subject_entity = ?2 AND predicate = ?3
224               AND object = ?4 AND valid_from = ?5 AND status = ?6",
225            rusqlite::params![space.id(), entity_id, predicate, object, valid_from, status],
226            |r| r.get(0),
227        )?;
228        if exists > 0 {
229            tx.commit()?;
230            return Ok(false);
231        }
232        tx.execute(
233            "INSERT INTO facts (space_id, subject_entity, predicate, object, confidence,
234                                valid_from, valid_until, status, status_reason)
235             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
236            rusqlite::params![
237                space.id(),
238                entity_id,
239                predicate,
240                object,
241                confidence,
242                valid_from,
243                valid_until,
244                status,
245                status_reason
246            ],
247        )?;
248        let fact_id = tx.last_insert_rowid();
249        for episode_id in episode_ids {
250            tx.execute(
251                "INSERT OR IGNORE INTO fact_provenance (fact_id, episode_id) VALUES (?1, ?2)",
252                rusqlite::params![fact_id, episode_id],
253            )?;
254        }
255        tx.commit()?;
256        Ok(true)
257    }
258}