Skip to main content

engram_core/migrate_handler/
logic.rs

1//! Pure migration logic: filtering, duplicate detection, bulk insert.
2//!
3//! No filesystem or environment access — all inputs are passed explicitly.
4
5use serde_json::{Value, json};
6
7use engram_storage::{Database, Memory, StorageError};
8
9use crate::error::CoreError;
10
11#[derive(Debug, Default, Clone, PartialEq, Eq)]
12pub struct MigrationStats {
13    pub read: usize,
14    pub matched: usize,
15    pub migrated: usize,
16    pub skipped_duplicate: usize,
17    pub skipped_null_project: usize,
18    pub skipped_other_project: usize,
19}
20
21impl MigrationStats {
22    pub fn to_json(&self, dry_run: bool, all: bool, project_hint: Option<&str>) -> Value {
23        json!({
24            "read": self.read,
25            "matched": self.matched,
26            "migrated": self.migrated,
27            "skipped_duplicate": self.skipped_duplicate,
28            "skipped_null_project": self.skipped_null_project,
29            "skipped_other_project": self.skipped_other_project,
30            "dry_run": dry_run,
31            "all": all,
32            "project_hint": project_hint,
33        })
34    }
35}
36
37/// Copies memories from `source` into `dest`.
38///
39/// Transaction semantics: `dest.bulk_insert_memories` wraps the inserts in a single
40/// SQLite transaction — either all queued rows commit or none do (all-or-nothing).
41/// Duplicates MUST therefore be filtered out before the bulk call; otherwise a single
42/// duplicate ID rolls back the entire batch.
43pub fn perform_migration_impl(
44    source: &Database,
45    dest: &Database,
46    project_hint: Option<&str>,
47    all: bool,
48    dry_run: bool,
49) -> Result<MigrationStats, CoreError> {
50    let memories = source.list_all_memories()?;
51    let mut stats = MigrationStats {
52        read: memories.len(),
53        ..MigrationStats::default()
54    };
55
56    let mut to_insert: Vec<Memory> = Vec::new();
57    for memory in memories {
58        if !memory_matches_filter(&memory, project_hint, all, &mut stats) {
59            continue;
60        }
61        stats.matched += 1;
62        if dest_contains_memory(dest, &memory.id)? {
63            stats.skipped_duplicate += 1;
64            continue;
65        }
66        let mut copy = memory;
67        // Force HNSW rebuild from embeddings on server startup; skip if already unindexed.
68        copy.indexed = false;
69        to_insert.push(copy);
70    }
71
72    if !dry_run && !to_insert.is_empty() {
73        dest.bulk_insert_memories(&to_insert)
74            .map_err(|error| CoreError::MigrationFailed(error.to_string()))?;
75    }
76    stats.migrated = if dry_run { 0 } else { to_insert.len() };
77    Ok(stats)
78}
79
80fn memory_matches_filter(
81    memory: &Memory,
82    project_hint: Option<&str>,
83    all: bool,
84    stats: &mut MigrationStats,
85) -> bool {
86    if all {
87        return true;
88    }
89    match (project_hint, memory.project.as_deref()) {
90        (None, _) => true,
91        (Some(_), None) => {
92            stats.skipped_null_project += 1;
93            false
94        }
95        (Some(hint), Some(project)) if project == hint => true,
96        (Some(_), Some(_)) => {
97            stats.skipped_other_project += 1;
98            false
99        }
100    }
101}
102
103fn dest_contains_memory(dest: &Database, id: &str) -> Result<bool, CoreError> {
104    match dest.get_memory(id) {
105        Ok(_) => Ok(true),
106        Err(StorageError::NotFound(_)) => Ok(false),
107        Err(error) => Err(CoreError::MigrationFailed(error.to_string())),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use tempfile::NamedTempFile;
115
116    fn sample_memory(id: &str, project: Option<&str>) -> Memory {
117        Memory {
118            id: id.to_string(),
119            memory_type: "decision".into(),
120            context: "context".into(),
121            action: "action".into(),
122            result: "result".into(),
123            score: 0.5,
124            embedding_context: None,
125            embedding_action: None,
126            embedding_result: None,
127            indexed: true,
128            tags: None,
129            project: project.map(|value| value.to_string()),
130            parent_id: None,
131            source_ids: None,
132            insight_type: None,
133            created_at: "2025-01-01T00:00:00Z".into(),
134            updated_at: "2025-01-01T00:00:00Z".into(),
135            used_count: 0,
136            last_used_at: None,
137            superseded_by: None,
138        }
139    }
140
141    fn insert_sample(database: &Database, memory: &Memory) {
142        database.insert_memory(memory).expect("seed insert");
143    }
144
145    #[test]
146    fn empty_source_returns_zero_stats() {
147        let source = Database::in_memory().expect("source");
148        let dest = Database::in_memory().expect("dest");
149        let stats = perform_migration_impl(&source, &dest, Some("engram"), false, false)
150            .expect("migration");
151        assert_eq!(stats, MigrationStats::default());
152    }
153
154    #[test]
155    fn all_flag_copies_all_rows_including_null_project() {
156        let source = Database::in_memory().expect("source");
157        let dest = Database::in_memory().expect("dest");
158        insert_sample(&source, &sample_memory("a", Some("engram")));
159        insert_sample(&source, &sample_memory("b", None));
160        insert_sample(&source, &sample_memory("c", Some("other")));
161
162        let stats = perform_migration_impl(&source, &dest, None, true, false).expect("migration");
163
164        assert_eq!(stats.read, 3);
165        assert_eq!(stats.matched, 3);
166        assert_eq!(stats.migrated, 3);
167        assert_eq!(stats.skipped_null_project, 0);
168        assert_eq!(stats.skipped_other_project, 0);
169        for id in ["a", "b", "c"] {
170            dest.get_memory(id).expect("row migrated");
171        }
172    }
173
174    #[test]
175    fn default_filter_matches_project_hint_exactly() {
176        let source = Database::in_memory().expect("source");
177        let dest = Database::in_memory().expect("dest");
178        insert_sample(&source, &sample_memory("match", Some("engram")));
179        insert_sample(&source, &sample_memory("null", None));
180        insert_sample(&source, &sample_memory("other", Some("different")));
181
182        let stats = perform_migration_impl(&source, &dest, Some("engram"), false, false)
183            .expect("migration");
184
185        assert_eq!(stats.read, 3);
186        assert_eq!(stats.matched, 1);
187        assert_eq!(stats.migrated, 1);
188        assert_eq!(stats.skipped_null_project, 1);
189        assert_eq!(stats.skipped_other_project, 1);
190        dest.get_memory("match").expect("match migrated");
191        assert!(matches!(
192            dest.get_memory("null"),
193            Err(StorageError::NotFound(_))
194        ));
195        assert!(matches!(
196            dest.get_memory("other"),
197            Err(StorageError::NotFound(_))
198        ));
199    }
200
201    #[test]
202    fn skips_duplicate_ids_without_rolling_back_batch() {
203        let source = Database::in_memory().expect("source");
204        let dest = Database::in_memory().expect("dest");
205        insert_sample(&source, &sample_memory("dup", Some("engram")));
206        insert_sample(&source, &sample_memory("new", Some("engram")));
207        insert_sample(&dest, &sample_memory("dup", Some("engram")));
208
209        let stats = perform_migration_impl(&source, &dest, Some("engram"), false, false)
210            .expect("migration");
211
212        assert_eq!(stats.read, 2);
213        assert_eq!(stats.matched, 2);
214        assert_eq!(stats.migrated, 1);
215        assert_eq!(stats.skipped_duplicate, 1);
216        dest.get_memory("new").expect("new migrated");
217    }
218
219    #[test]
220    fn dry_run_counts_without_writing() {
221        let source = Database::in_memory().expect("source");
222        let dest = Database::in_memory().expect("dest");
223        insert_sample(&source, &sample_memory("a", Some("engram")));
224        insert_sample(&source, &sample_memory("b", Some("engram")));
225
226        let stats =
227            perform_migration_impl(&source, &dest, Some("engram"), false, true).expect("migration");
228
229        assert_eq!(stats.read, 2);
230        assert_eq!(stats.matched, 2);
231        assert_eq!(stats.migrated, 0);
232        assert_eq!(stats.skipped_duplicate, 0);
233        assert!(matches!(
234            dest.get_memory("a"),
235            Err(StorageError::NotFound(_))
236        ));
237        assert!(matches!(
238            dest.get_memory("b"),
239            Err(StorageError::NotFound(_))
240        ));
241    }
242
243    #[test]
244    fn migrated_row_has_indexed_false_for_hnsw_rebuild() {
245        let source = Database::in_memory().expect("source");
246        let dest = Database::in_memory().expect("dest");
247        let mut memory = sample_memory("x", Some("engram"));
248        memory.indexed = true;
249        insert_sample(&source, &memory);
250
251        let stats = perform_migration_impl(&source, &dest, Some("engram"), false, false)
252            .expect("migration");
253        assert_eq!(stats.migrated, 1);
254
255        let migrated = dest.get_memory("x").expect("migrated row");
256        assert!(
257            !migrated.indexed,
258            "migrated rows must be unindexed so server rebuilds HNSW"
259        );
260    }
261
262    #[test]
263    fn migrate_reports_6019_when_dest_write_fails() {
264        let source = Database::in_memory().expect("source");
265        insert_sample(&source, &sample_memory("id-1", Some("proj")));
266
267        let dest_file = NamedTempFile::new().expect("temp dest file");
268        let dest_path = dest_file.path().to_str().expect("utf-8");
269        // Initialize schema with a read-write open, then drop the handle before reopening read-only.
270        {
271            let _schema = Database::open(dest_path).expect("init schema");
272        }
273        let dest = Database::open_read_only(dest_path).expect("reopen dest read-only");
274
275        let result = perform_migration_impl(&source, &dest, Some("proj"), false, false);
276
277        let error = result.expect_err("write to read-only dest must fail");
278        assert!(matches!(error, CoreError::MigrationFailed(_)));
279        let message = error.to_string();
280        assert!(
281            message.contains("[6019]"),
282            "expected 6019 migration failure, got: {message}"
283        );
284    }
285}